Skip to content

Commit bf412ed

Browse files
committed
Cycles: Support for custom OSL cameras
This allows users to implement arbitrary camera models using OSL by writing shaders that take an image position as input and compute ray origin and direction. The obvious applications for this are e.g. panorama modes, lens distortion models and realistic lens simulation, but the possibilities are endless. Currently, this is only supported on devices with OSL support, so CPU and OptiX. However, it is independent from the shading model used, so custom cameras can be used without getting the performance hit of OSL shading. A few samples are provided as Text Editor templates. One notable current limitation (in addition to the limited device support) is that inverse mapping is not supported, so Window texture coordinates and the Vector pass will not work with custom cameras. Pull Request: https://projects.blender.org/blender/blender/pulls/129495
1 parent 81c00bf commit bf412ed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1808
-337
lines changed

intern/cycles/blender/addon/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ def update_script_node(self, node):
111111
else:
112112
self.report({'ERROR'}, "OSL support disabled in this build")
113113

114+
def update_custom_camera(self, cam):
115+
if engine.with_osl():
116+
from . import osl
117+
osl.update_custom_camera_shader(cam, self.report)
118+
else:
119+
self.report({'ERROR'}, "OSL support disabled in this build")
120+
114121
def update_render_passes(self, scene, srl):
115122
engine.register_passes(self, scene, srl)
116123

intern/cycles/blender/addon/osl.py

Lines changed: 205 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -89,83 +89,172 @@ def shader_param_ensure(node, param):
8989
return sock
9090

9191

92-
def update_script_node(node, report):
93-
"""compile and update shader script node"""
92+
def osl_param_ensure_property(ccam, param):
93+
import idprop
94+
95+
if param.isoutput or param.isclosure:
96+
return None
97+
98+
# Get metadata for the parameter to control UI display
99+
metadata = {meta.name: meta.value for meta in param.metadata}
100+
if 'label' not in metadata:
101+
metadata['label'] = param.name
102+
103+
datatype = None
104+
if param.type.basetype == param.type.basetype.INT:
105+
datatype = int
106+
elif param.type.basetype == param.type.basetype.FLOAT:
107+
datatype = float
108+
elif param.type.basetype == param.type.basetype.STRING:
109+
datatype = str
110+
111+
# OSl doesn't have boolean as a type, but we do
112+
if (datatype == int) and (metadata.get('widget') in ('boolean', 'checkBox')):
113+
datatype = bool
114+
default = param.value if isinstance(param.value, tuple) else [param.value]
115+
default = [datatype(v) for v in default]
116+
117+
name = param.name
118+
if name in ccam:
119+
# If the parameter already exists, only reset its value if its type
120+
# or array length changed
121+
cur_data = ccam[name]
122+
if isinstance(cur_data, idprop.types.IDPropertyArray):
123+
cur_length = len(cur_data)
124+
cur_type = type(cur_data[0])
125+
else:
126+
cur_length = 1
127+
cur_type = type(cur_data)
128+
do_replace = datatype != cur_type or len(default) != cur_length
129+
else:
130+
# Parameter doesn't exist yet, so set it from the defaults
131+
do_replace = True
132+
133+
if do_replace:
134+
ccam[name] = tuple(default) if len(default) > 1 else default[0]
135+
136+
ui = ccam.id_properties_ui(name)
137+
ui.clear()
138+
139+
# Determine subtype (no unit support for now)
140+
if param.type.vecsemantics == param.type.vecsemantics.COLOR:
141+
ui.update(subtype='COLOR')
142+
elif metadata.get('slider'):
143+
ui.update(subtype='FACTOR')
144+
145+
# Map OSL metadata to Blender names
146+
option_map = {
147+
'help': 'description',
148+
'sensitivity': 'step', 'digits': 'precision',
149+
'min': 'min', 'max': 'max',
150+
'slidermin': 'soft_min', 'slidermax': 'soft_max',
151+
}
152+
if 'sensitivity' in metadata:
153+
# Blender divides this value by 100 by convention, so counteract that.
154+
metadata['sensitivity'] *= 100
155+
for option, value in metadata.items():
156+
if option in option_map:
157+
ui.update(**{option_map[option]: value})
158+
159+
return name
160+
161+
162+
def update_external_script(report, filepath, library):
163+
"""compile and update OSL script"""
94164
import os
95165
import shutil
166+
167+
oso_file_remove = False
168+
169+
script_path = bpy.path.abspath(filepath, library=library)
170+
script_path_noext, script_ext = os.path.splitext(script_path)
171+
172+
if script_ext == ".oso":
173+
# it's a .oso file, no need to compile
174+
ok, oso_path = True, script_path
175+
elif script_ext == ".osl":
176+
# compile .osl file
177+
ok, oso_path = osl_compile(script_path, report)
178+
oso_file_remove = True
179+
180+
if ok:
181+
# copy .oso from temporary path to .osl directory
182+
dst_path = script_path_noext + ".oso"
183+
try:
184+
shutil.copy2(oso_path, dst_path)
185+
except:
186+
report({'ERROR'}, "Failed to write .oso file next to external .osl file at " + dst_path)
187+
elif os.path.dirname(filepath) == "":
188+
# module in search path
189+
oso_path = filepath
190+
ok = True
191+
else:
192+
# unknown
193+
report({'ERROR'}, "External shader script must have .osl or .oso extension, or be a module name")
194+
ok = False
195+
196+
return ok, oso_path, oso_file_remove
197+
198+
199+
def update_internal_script(report, script):
200+
"""compile and update shader script node"""
201+
import os
96202
import tempfile
97-
import hashlib
98203
import pathlib
204+
import hashlib
205+
206+
bytecode = None
207+
bytecode_hash = None
208+
209+
osl_path = bpy.path.abspath(script.filepath, library=script.library)
210+
211+
if script.is_in_memory or script.is_dirty or script.is_modified or not os.path.exists(osl_path):
212+
# write text datablock contents to temporary file
213+
osl_file = tempfile.NamedTemporaryFile(mode='w', suffix=".osl", delete=False)
214+
osl_file.write(script.as_string())
215+
osl_file.write("\n")
216+
osl_file.close()
217+
218+
ok, oso_path = osl_compile(osl_file.name, report)
219+
os.remove(osl_file.name)
220+
else:
221+
# compile text datablock from disk directly
222+
ok, oso_path = osl_compile(osl_path, report)
223+
224+
if ok:
225+
# read bytecode
226+
try:
227+
bytecode = pathlib.Path(oso_path).read_text()
228+
md5 = hashlib.md5(usedforsecurity=False)
229+
md5.update(bytecode.encode())
230+
bytecode_hash = md5.hexdigest()
231+
except:
232+
import traceback
233+
traceback.print_exc()
234+
235+
report({'ERROR'}, "Can't read OSO bytecode to store in node at %r" % oso_path)
236+
ok = False
237+
238+
return ok, oso_path, bytecode, bytecode_hash
239+
240+
241+
def update_script_node(node, report):
242+
"""compile and update shader script node"""
243+
import os
99244
import oslquery
100245

101246
oso_file_remove = False
102247

103248
if node.mode == 'EXTERNAL':
104249
# compile external script file
105-
script_path = bpy.path.abspath(node.filepath, library=node.id_data.library)
106-
script_path_noext, script_ext = os.path.splitext(script_path)
107-
108-
if script_ext == ".oso":
109-
# it's a .oso file, no need to compile
110-
ok, oso_path = True, script_path
111-
elif script_ext == ".osl":
112-
# compile .osl file
113-
ok, oso_path = osl_compile(script_path, report)
114-
oso_file_remove = True
115-
116-
if ok:
117-
# copy .oso from temporary path to .osl directory
118-
dst_path = script_path_noext + ".oso"
119-
try:
120-
shutil.copy2(oso_path, dst_path)
121-
except:
122-
report({'ERROR'}, "Failed to write .oso file next to external .osl file at " + dst_path)
123-
elif os.path.dirname(node.filepath) == "":
124-
# module in search path
125-
oso_path = node.filepath
126-
ok = True
127-
else:
128-
# unknown
129-
report({'ERROR'}, "External shader script must have .osl or .oso extension, or be a module name")
130-
ok = False
131-
132-
if ok:
133-
node.bytecode = ""
134-
node.bytecode_hash = ""
250+
ok, oso_path, oso_file_remove = update_external_script(report, node.filepath, node.id_data.library)
135251

136252
elif node.mode == 'INTERNAL' and node.script:
137253
# internal script, we will store bytecode in the node
138-
script = node.script
139-
osl_path = bpy.path.abspath(script.filepath, library=script.library)
140-
141-
if script.is_in_memory or script.is_dirty or script.is_modified or not os.path.exists(osl_path):
142-
# write text datablock contents to temporary file
143-
osl_file = tempfile.NamedTemporaryFile(mode='w', suffix=".osl", delete=False)
144-
osl_file.write(script.as_string())
145-
osl_file.write("\n")
146-
osl_file.close()
147-
148-
ok, oso_path = osl_compile(osl_file.name, report)
149-
os.remove(osl_file.name)
150-
else:
151-
# compile text datablock from disk directly
152-
ok, oso_path = osl_compile(osl_path, report)
153-
154-
if ok:
155-
# read bytecode
156-
try:
157-
bytecode = pathlib.Path(oso_path).read_text()
158-
md5 = hashlib.md5(usedforsecurity=False)
159-
md5.update(bytecode.encode())
160-
161-
node.bytecode = bytecode
162-
node.bytecode_hash = md5.hexdigest()
163-
except:
164-
import traceback
165-
traceback.print_exc()
166-
167-
report({'ERROR'}, "Can't read OSO bytecode to store in node at %r" % oso_path)
168-
ok = False
254+
ok, oso_path, bytecode, bytecode_hash = update_internal_script(report, node.script)
255+
if bytecode:
256+
node.bytecode = bytecode
257+
node.bytecode_hash = bytecode_hash
169258

170259
else:
171260
report({'WARNING'}, "No text or file specified in node, nothing to compile")
@@ -198,3 +287,55 @@ def update_script_node(node, report):
198287
pass
199288

200289
return ok
290+
291+
292+
def update_custom_camera_shader(cam, report):
293+
"""compile and update custom camera shader"""
294+
import os
295+
import oslquery
296+
297+
oso_file_remove = False
298+
299+
custom_props = cam.cycles_custom
300+
if cam.custom_mode == 'EXTERNAL':
301+
# compile external script file
302+
ok, oso_path, oso_file_remove = update_external_script(report, cam.custom_filepath, cam.library)
303+
304+
elif cam.custom_mode == 'INTERNAL' and cam.custom_shader:
305+
# internal script, we will store bytecode in the node
306+
ok, oso_path, bytecode, bytecode_hash = update_internal_script(report, cam.custom_shader)
307+
if bytecode:
308+
cam.custom_bytecode = bytecode
309+
cam.custom_bytecode_hash = bytecode_hash
310+
cam.update_tag()
311+
312+
else:
313+
report({'WARNING'}, "No text or file specified in node, nothing to compile")
314+
return
315+
316+
if ok:
317+
if query := oslquery.OSLQuery(oso_path):
318+
# Ensure that all parameters have a matching property
319+
used_params = set()
320+
for param in query.parameters:
321+
if name := osl_param_ensure_property(custom_props, param):
322+
used_params.add(name)
323+
324+
# Clean up unused parameters
325+
for prop in list(custom_props.keys()):
326+
if prop not in used_params:
327+
del custom_props[prop]
328+
else:
329+
ok = False
330+
report({'ERROR'}, tip_("OSL query failed to open %s") % oso_path)
331+
else:
332+
report({'ERROR'}, "Custom Camera shader compilation failed, see console for errors")
333+
334+
# remove temporary oso file
335+
if oso_file_remove:
336+
try:
337+
os.remove(oso_path)
338+
except:
339+
pass
340+
341+
return ok

intern/cycles/blender/addon/properties.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,21 @@ def unregister(cls):
11041104
del bpy.types.Scene.cycles
11051105

11061106

1107+
class CyclesCustomCameraSettings(bpy.types.PropertyGroup):
1108+
1109+
@classmethod
1110+
def register(cls):
1111+
bpy.types.Camera.cycles_custom = PointerProperty(
1112+
name="Cycles Custom Camera Settings",
1113+
description="Parameters for custom (OSL-based) Cameras",
1114+
type=cls,
1115+
)
1116+
1117+
@classmethod
1118+
def unregister(cls):
1119+
del bpy.types.Camera.cycles_custom
1120+
1121+
11071122
class CyclesMaterialSettings(bpy.types.PropertyGroup):
11081123

11091124
emission_sampling: EnumProperty(
@@ -1900,6 +1915,7 @@ class CyclesView3DShadingSettings(bpy.types.PropertyGroup):
19001915

19011916
def register():
19021917
bpy.utils.register_class(CyclesRenderSettings)
1918+
bpy.utils.register_class(CyclesCustomCameraSettings)
19031919
bpy.utils.register_class(CyclesMaterialSettings)
19041920
bpy.utils.register_class(CyclesLightSettings)
19051921
bpy.utils.register_class(CyclesWorldSettings)
@@ -1920,6 +1936,7 @@ def register():
19201936

19211937
def unregister():
19221938
bpy.utils.unregister_class(CyclesRenderSettings)
1939+
bpy.utils.unregister_class(CyclesCustomCameraSettings)
19231940
bpy.utils.unregister_class(CyclesMaterialSettings)
19241941
bpy.utils.unregister_class(CyclesLightSettings)
19251942
bpy.utils.unregister_class(CyclesWorldSettings)

intern/cycles/blender/addon/ui.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,30 @@ def draw(self, context):
11771177
col.prop(dof, "aperture_ratio")
11781178

11791179

1180+
class CYCLES_CAMERA_PT_lens_custom_parameters(CyclesButtonsPanel, Panel):
1181+
bl_label = "Parameters"
1182+
bl_parent_id = "DATA_PT_lens"
1183+
1184+
@classmethod
1185+
def poll(cls, context):
1186+
cam = context.camera
1187+
return (super().poll(context) and
1188+
cam and
1189+
cam.type == 'CUSTOM' and
1190+
len(cam.cycles_custom.keys()) > 0)
1191+
1192+
def draw(self, context):
1193+
layout = self.layout
1194+
layout.use_property_split = True
1195+
1196+
cam = context.camera
1197+
ccam = cam.cycles_custom
1198+
1199+
col = layout.column()
1200+
for key in ccam.keys():
1201+
col.prop(ccam, f'["{key}"]')
1202+
1203+
11801204
class CYCLES_PT_context_material(CyclesButtonsPanel, Panel):
11811205
bl_label = ""
11821206
bl_context = "material"
@@ -2500,6 +2524,7 @@ def get_panels():
25002524
CYCLES_PT_post_processing,
25012525
CYCLES_CAMERA_PT_dof,
25022526
CYCLES_CAMERA_PT_dof_aperture,
2527+
CYCLES_CAMERA_PT_lens_custom_parameters,
25032528
CYCLES_PT_context_material,
25042529
CYCLES_OBJECT_PT_motion_blur,
25052530
CYCLES_OBJECT_PT_shading_gi_approximation,

0 commit comments

Comments
 (0)