LNXSDK/leenkx/blender/lnx/write_probes.py
2025-01-22 16:18:30 +01:00

485 lines
17 KiB
Python

from contextlib import contextmanager
import math
import multiprocessing
import os
import re
import subprocess
import time
import bpy
import lnx.assets as assets
import lnx.log as log
import lnx.utils
if lnx.is_reload(__name__):
import lnx
assets = lnx.reload_module(assets)
log = lnx.reload_module(log)
lnx.utils = lnx.reload_module(lnx.utils)
else:
lnx.enable_reload(__name__)
# The format used for rendering the environment. Choose HDR or JPEG.
ENVMAP_FORMAT = 'JPEG'
ENVMAP_EXT = 'hdr' if ENVMAP_FORMAT == 'HDR' else 'jpg'
__cmft_start_time_seconds = 0.0
__cmft_end_time_seconds = 0.0
def add_irr_assets(output_file_irr):
assets.add(output_file_irr + '.lnx')
def add_rad_assets(output_file_rad, rad_format, num_mips):
assets.add(output_file_rad + '.' + rad_format)
for i in range(0, num_mips):
assets.add(output_file_rad + '_' + str(i) + '.' + rad_format)
@contextmanager
def setup_envmap_render():
"""Creates a background scene for rendering environment textures.
Use it as a context manager to automatically clean up on errors.
"""
rpdat = lnx.utils.get_rp()
radiance_size = int(rpdat.lnx_radiance_size)
# Render worlds in a different scene so that there are no other
# objects. The actual scene might be called differently if the name
# is already taken
scene = bpy.data.scenes.new("_lnx_envmap_render")
scene.render.engine = "CYCLES"
scene.render.image_settings.file_format = ENVMAP_FORMAT
if ENVMAP_FORMAT == 'HDR':
scene.render.image_settings.color_depth = '32'
# Export in linear space and with default color management settings
if bpy.app.version < (4, 1, 0):
scene.display_settings.display_device = "None"
else:
scene.display_settings.display_device = "sRGB"
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"
scene.view_settings.exposure = 0
scene.view_settings.gamma = 1
scene.render.image_settings.quality = 100
scene.render.resolution_x = radiance_size
scene.render.resolution_y = radiance_size // 2
# Set GPU as rendering device if the user enabled it
if bpy.context.preferences.addons["cycles"].preferences.compute_device_type != "NONE":
scene.cycles.device = "GPU"
else:
log.info('Using CPU for environment render (might be slow). If possible, configure GPU rendering in Blender preferences (System > Cycles Render Devices).')
# Those settings are sufficient for rendering only the world background
scene.cycles.samples = 1
scene.cycles.max_bounces = 1
scene.cycles.diffuse_bounces = 1
scene.cycles.glossy_bounces = 1
scene.cycles.transmission_bounces = 1
scene.cycles.volume_bounces = 1
scene.cycles.transparent_max_bounces = 1
scene.cycles.caustics_reflective = False
scene.cycles.caustics_refractive = False
scene.cycles.use_denoising = False
# Setup scene
cam = bpy.data.cameras.new("_lnx_cam_envmap_render")
cam_obj = bpy.data.objects.new("_lnx_cam_envmap_render", cam)
scene.collection.objects.link(cam_obj)
scene.camera = cam_obj
cam_obj.location = [0.0, 0.0, 0.0]
cam.type = "PANO"
if bpy.app.version < (4, 1, 0):
cam.cycles.panorama_type = "EQUIRECTANGULAR"
else:
cam.panorama_type = "EQUIRECTANGULAR"
cam_obj.rotation_euler = [math.radians(90), 0, math.radians(-90)]
try:
yield
finally:
bpy.data.objects.remove(cam_obj)
bpy.data.cameras.remove(cam)
bpy.data.scenes.remove(scene)
def render_envmap(target_dir: str, world: bpy.types.World) -> str:
"""Render an environment texture for the given world into the
target_dir and return the filename of the rendered image. Use in
combination with setup_envmap_render().
"""
scene = bpy.data.scenes['_lnx_envmap_render']
scene.world = world
image_name = f'env_{lnx.utils.safesrc(world.name)}.{ENVMAP_EXT}'
render_path = os.path.join(target_dir, image_name)
scene.render.filepath = render_path
bpy.ops.render.render(write_still=True, scene=scene.name)
return image_name
def write_probes(image_filepath: str, disable_hdr: bool, from_srgb: bool, cached_num_mips: int, lnx_radiance=True) -> int:
"""Generate probes from environment map and return the mipmap count."""
envpath = lnx.utils.get_fp_build() + '/compiled/Assets/envmaps'
if not os.path.exists(envpath):
os.makedirs(envpath)
base_name = lnx.utils.extract_filename(image_filepath).rsplit('.', 1)[0]
# Assets to be generated
output_file_irr = envpath + '/' + base_name + '_irradiance'
output_file_rad = envpath + '/' + base_name + '_radiance'
rad_format = 'jpg' if disable_hdr else 'hdr'
# Radiance & irradiance exists, keep cache
basep = envpath + '/' + base_name
if os.path.exists(basep + '_irradiance.lnx'):
if not lnx_radiance or os.path.exists(basep + '_radiance_0.' + rad_format):
add_irr_assets(output_file_irr)
if lnx_radiance:
add_rad_assets(output_file_rad, rad_format, cached_num_mips)
return cached_num_mips
# Get paths
sdk_path = lnx.utils.get_sdk_path()
kha_path = lnx.utils.get_kha_path()
if lnx.utils.get_os() == 'win':
cmft_path = sdk_path + '/lib/leenkx_tools/cmft/cmft.exe'
kraffiti_path = kha_path + '/Kinc/Tools/windows_x64/kraffiti.exe'
elif lnx.utils.get_os() == 'mac':
cmft_path = '"' + sdk_path + '/lib/leenkx_tools/cmft/cmft-osx"'
kraffiti_path = '"' + kha_path + '/Kinc/Tools/macos/kraffiti"'
else:
cmft_path = '"' + sdk_path + '/lib/leenkx_tools/cmft/cmft-linux64"'
kraffiti_path = '"' + kha_path + '/Kinc/Tools/linux_x64/kraffiti"'
input_file = lnx.utils.asset_path(image_filepath)
# Scale map, ensure 2:1 ratio (required by cmft)
rpdat = lnx.utils.get_rp()
target_w = int(rpdat.lnx_radiance_size)
target_h = int(target_w / 2)
scaled_file = output_file_rad + '.' + rad_format
if lnx.utils.get_os() == 'win':
subprocess.check_output([
kraffiti_path,
'from=' + input_file,
'to=' + scaled_file,
'format=' + rad_format,
'width=' + str(target_w),
'height=' + str(target_h)])
else:
subprocess.check_output([
kraffiti_path
+ ' from="' + input_file + '"'
+ ' to="' + scaled_file + '"'
+ ' format=' + rad_format
+ ' width=' + str(target_w)
+ ' height=' + str(target_h)], shell=True)
# Convert sRGB colors into linear color space first (approximately)
input_gamma_numerator = '2.2' if from_srgb else '1.0'
# Irradiance spherical harmonics
if lnx.utils.get_os() == 'win':
subprocess.call([
cmft_path,
'--input', scaled_file,
'--filter', 'shcoeffs',
'--outputNum', '1',
'--output0', output_file_irr,
'--inputGammaNumerator', input_gamma_numerator,
'--inputGammaDenominator', '1.0',
'--outputGammaNumerator', '1.0',
'--outputGammaDenominator', '1.0'
])
else:
subprocess.call([
cmft_path
+ ' --input ' + '"' + scaled_file + '"'
+ ' --filter shcoeffs'
+ ' --outputNum 1'
+ ' --output0 ' + '"' + output_file_irr + '"'
+ ' --inputGammaNumerator' + input_gamma_numerator
+ ' --inputGammaDenominator' + '1.0'
+ ' --outputGammaNumerator' + '1.0'
+ ' --outputGammaDenominator' + '1.0'
], shell=True)
sh_to_json(output_file_irr)
add_irr_assets(output_file_irr)
# Mip-mapped radiance
if not lnx_radiance:
return cached_num_mips
# 4096 = 256 face
# 2048 = 128 face
# 1024 = 64 face
face_size = target_w / 8
if target_w == 2048:
mip_count = 9
elif target_w == 1024:
mip_count = 8
else:
mip_count = 7
wrd = bpy.data.worlds['Lnx']
use_opencl = 'true' if lnx.utils.get_pref_or_default("cmft_use_opencl", True) else 'false'
# cmft doesn't work correctly when passing the number of logical
# CPUs if there are more logical than physical CPUs on a machine
cpu_count = lnx.utils.cpu_count(physical_only=True)
# CMFT might hang with OpenCl enabled, output warning in that case.
# See https://github.com/leenkx3d/leenkx/issues/2760 for details.
global __cmft_start_time_seconds, __cmft_end_time_seconds
__cmft_start_time_seconds = time.time()
try:
if lnx.utils.get_os() == 'win':
cmd = [
cmft_path,
'--input', scaled_file,
'--filter', 'radiance',
'--dstFaceSize', str(face_size),
'--srcFaceSize', str(face_size),
'--excludeBase', 'false',
# '--mipCount', str(mip_count),
'--glossScale', '8',
'--glossBias', '3',
'--lightingModel', 'blinnbrdf',
'--edgeFixup', 'none',
'--numCpuProcessingThreads', str(cpu_count),
'--useOpenCL', use_opencl,
'--clVendor', 'anyGpuVendor',
'--deviceType', 'gpu',
'--deviceIndex', '0',
'--generateMipChain', 'true',
'--inputGammaNumerator', input_gamma_numerator,
'--inputGammaDenominator', '1.0',
'--outputGammaNumerator', '1.0',
'--outputGammaDenominator', '1.0',
'--outputNum', '1',
'--output0', output_file_rad,
'--output0params', 'hdr,rgbe,latlong'
]
if not wrd.lnx_verbose_output:
cmd.append('--silent')
print(cmd)
subprocess.call(cmd)
else:
cmd = cmft_path + \
' --input "' + scaled_file + '"' + \
' --filter radiance' + \
' --dstFaceSize ' + str(face_size) + \
' --srcFaceSize ' + str(face_size) + \
' --excludeBase false' + \
' --glossScale 8' + \
' --glossBias 3' + \
' --lightingModel blinnbrdf' + \
' --edgeFixup none' + \
' --numCpuProcessingThreads ' + str(cpu_count) + \
' --useOpenCL ' + use_opencl + \
' --clVendor anyGpuVendor' + \
' --deviceType gpu' + \
' --deviceIndex 0' + \
' --generateMipChain true' + \
' --inputGammaNumerator ' + input_gamma_numerator + \
' --inputGammaDenominator 1.0' + \
' --outputGammaNumerator 1.0' + \
' --outputGammaDenominator 1.0' + \
' --outputNum 1' + \
' --output0 "' + output_file_rad + '"' + \
' --output0params hdr,rgbe,latlong'
if not wrd.lnx_verbose_output:
cmd += ' --silent'
print(cmd)
subprocess.call([cmd], shell=True)
except KeyboardInterrupt as e:
__cmft_end_time_seconds = time.time()
check_last_cmft_time()
raise e
__cmft_end_time_seconds = time.time()
# Remove size extensions in file name
mip_w = int(face_size * 4)
mip_base = output_file_rad + '_'
mip_num = 0
while mip_w >= 4:
mip_name = mip_base + str(mip_num)
os.rename(
mip_name + '_' + str(mip_w) + 'x' + str(int(mip_w / 2)) + '.hdr',
mip_name + '.hdr')
mip_w = int(mip_w / 2)
mip_num += 1
# Append mips
generated_files = []
for i in range(0, mip_count):
generated_files.append(output_file_rad + '_' + str(i))
# Convert to jpgs
if disable_hdr is True:
for f in generated_files:
if lnx.utils.get_os() == 'win':
subprocess.call([
kraffiti_path,
'from=' + f + '.hdr',
'to=' + f + '.jpg',
'format=jpg'])
else:
subprocess.call([
kraffiti_path
+ ' from="' + f + '.hdr"'
+ ' to="' + f + '.jpg"'
+ ' format=jpg'], shell=True)
os.remove(f + '.hdr')
# Scale from (4x2 to 1x1>
for i in range(0, 2):
last = generated_files[-1]
out = output_file_rad + '_' + str(mip_count + i)
if lnx.utils.get_os() == 'win':
subprocess.call([
kraffiti_path,
'from=' + last + '.' + rad_format,
'to=' + out + '.' + rad_format,
'scale=0.5',
'format=' + rad_format], shell=True)
else:
subprocess.call([
kraffiti_path
+ ' from=' + '"' + last + '.' + rad_format + '"'
+ ' to=' + '"' + out + '.' + rad_format + '"'
+ ' scale=0.5'
+ ' format=' + rad_format], shell=True)
generated_files.append(out)
mip_count += 2
add_rad_assets(output_file_rad, rad_format, mip_count)
return mip_count
def sh_to_json(sh_file):
"""Parse sh coefs produced by cmft into json array"""
with open(sh_file + '.c') as f:
sh_lines = f.read().splitlines()
band0_line = sh_lines[5]
band1_line = sh_lines[6]
band2_line = sh_lines[7]
irradiance_floats = []
parse_band_floats(irradiance_floats, band0_line)
parse_band_floats(irradiance_floats, band1_line)
parse_band_floats(irradiance_floats, band2_line)
# Lower exposure to adjust to Eevee and Cycles
for i in range(0, len(irradiance_floats)):
irradiance_floats[i] /= 2
sh_json = {'irradiance': irradiance_floats}
ext = '.lnx' if bpy.data.worlds['Lnx'].lnx_minimize else ''
lnx.utils.write_lnx(sh_file + ext, sh_json)
# Clean up .c
os.remove(sh_file + '.c')
def parse_band_floats(irradiance_floats, band_line):
string_floats = re.findall(r'[-+]?\d*\.\d+|\d+', band_line)
string_floats = string_floats[1:] # Remove 'Band 0/1/2' number
for s in string_floats:
irradiance_floats.append(float(s))
def write_sky_irradiance(base_name):
# Hosek spherical harmonics
irradiance_floats = [
1.5519331988822218, 2.3352207154503266, 2.997277451988076,
0.2673894962434794, 0.4305630474135794, 0.11331825259716752,
-0.04453633521758638, -0.038753175134160295, -0.021302768541875794,
0.00055858020486499, 0.000371654770334503, 0.000126606145406403,
-0.000135708721978705, -0.000787399554583089, -0.001550090690860059,
0.021947399048903773, 0.05453650591711572, 0.08783641266630278,
0.17053593578630663, 0.14734127083304463, 0.07775404698816404,
-2.6924363189795e-05, -7.9350169701934e-05, -7.559914435231e-05,
0.27035455385870993, 0.23122918445556914, 0.12158817295211832]
for i in range(0, len(irradiance_floats)):
irradiance_floats[i] /= 2
envpath = os.path.join(lnx.utils.get_fp_build(), 'compiled', 'Assets', 'envmaps')
if not os.path.exists(envpath):
os.makedirs(envpath)
output_file = os.path.join(envpath, base_name + '_irradiance')
sh_json = {'irradiance': irradiance_floats}
lnx.utils.write_lnx(output_file + '.lnx', sh_json)
assets.add(output_file + '.lnx')
def write_color_irradiance(base_name, col):
"""Constant color irradiance"""
# Adjust to Cycles
irradiance_floats = [col[0] * 1.13, col[1] * 1.13, col[2] * 1.13]
for i in range(0, 24):
irradiance_floats.append(0.0)
envpath = os.path.join(lnx.utils.get_fp_build(), 'compiled', 'Assets', 'envmaps')
if not os.path.exists(envpath):
os.makedirs(envpath)
output_file = os.path.join(envpath, base_name + '_irradiance')
sh_json = {'irradiance': irradiance_floats}
lnx.utils.write_lnx(output_file + '.lnx', sh_json)
assets.add(output_file + '.lnx')
def check_last_cmft_time():
global __cmft_start_time_seconds, __cmft_end_time_seconds
if __cmft_start_time_seconds <= 0.0:
# CMFT was not called
return
if __cmft_end_time_seconds <= 0.0:
# Build was aborted and CMFT didn't finish
__cmft_end_time_seconds = time.time()
cmft_duration_seconds = __cmft_end_time_seconds - __cmft_start_time_seconds
# We could also check here if the user already disabled OpenCL and
# then don't show the warning, but this might trick users into
# thinking they have fixed their issue even in the case that the
# slow runtime isn't caused by using OpenCL
if cmft_duration_seconds > 20:
log.warn(
"Generating the radiance map with CMFT took an unusual amount"
f" of time ({cmft_duration_seconds:.2f} s). If the issue persists,"
" try disabling \"CMFT: Use OpenCL\" in the Leenkx add-on preferences."
" For more information see https://github.com/leenkx3d/leenkx/issues/2760."
)
__cmft_start_time_seconds = 0.0
__cmft_end_time_seconds = 0.0