| … | … | @@ -48,6 +48,7 @@ if _HERE not in sys.path: |
| 48 | 48 | |
| 49 | 49 | import ifs_codegen |
| 50 | 50 | import ifs_document |
| 51 | import ifs_hdri | |
| 51 | 52 | import ifs_points |
| 52 | 53 | import ifs_render |
| 53 | 54 | import ifs_viewport |
| … | … | @@ -214,6 +215,19 @@ class IFSSceneSettings(PropertyGroup): |
| 214 | 215 | description="Emission strength of the point material. Unlike the volume's " |
| 215 | 216 | "exposure this needs no derivation - a point is a surface the " |
| 216 | 217 | "renderer sees directly, not a density integrated along a ray") |
| 218 | hdri_width: IntProperty( | |
| 219 | name="Width", default=2048, min=256, max=8192, | |
| 220 | description="Equirectangular width. Height is half of it — u spans 360 " | |
| 221 | "degrees of azimuth and v 180 of elevation, so square pixels " | |
| 222 | "need a 2:1 image") | |
| 223 | hdri_dispatches: IntProperty( | |
| 224 | name="Dispatches", default=200, min=1, max=100000, | |
| 225 | description="Batches of 16384 x 500 iterations. More is cleaner and slower") | |
| 226 | hdri_set_world: BoolProperty( | |
| 227 | name="Set as world background", default=True, | |
| 228 | description="Point the World's Background node at the baked image. An " | |
| 229 | "environment is infinitely far away, so it is never occluded " | |
| 230 | "and it lights the scene in both EEVEE and Cycles") | |
| 217 | 231 | iterators: CollectionProperty(type=IFSIteratorItem) |
| 218 | 232 | active_iterator: IntProperty(default=0) |
| 219 | 233 | status: StringProperty(default="") |
| … | … | @@ -544,6 +558,75 @@ class IFS_OT_emit_points(Operator): |
| 544 | 558 | return {'FINISHED'} |
| 545 | 559 | |
| 546 | 560 | |
| 561 | def _wire_world(image): | |
| 562 | """ | |
| 563 | Points the World at the baked image. | |
| 564 | ||
| 565 | Rebuilt rather than patched: a World may carry anything, and hunting for an | |
| 566 | existing Environment Texture to repoint is how you end up wired to the node | |
| 567 | the user was actually using for something else. | |
| 568 | """ | |
| 569 | world = bpy.context.scene.world | |
| 570 | if world is None: | |
| 571 | world = bpy.data.worlds.new("IFS World") | |
| 572 | bpy.context.scene.world = world | |
| 573 | world.use_nodes = True | |
| 574 | tree = world.node_tree | |
| 575 | tree.nodes.clear() | |
| 576 | output = tree.nodes.new('ShaderNodeOutputWorld') | |
| 577 | background = tree.nodes.new('ShaderNodeBackground') | |
| 578 | env = tree.nodes.new('ShaderNodeTexEnvironment') | |
| 579 | env.image = image | |
| 580 | tree.links.new(env.outputs['Color'], background.inputs['Color']) | |
| 581 | tree.links.new(background.outputs['Background'], output.inputs['Surface']) | |
| 582 | return world | |
| 583 | ||
| 584 | ||
| 585 | class IFS_OT_bake_hdri(Operator): | |
| 586 | """Bake the fractal to an equirectangular environment map""" | |
| 587 | ||
| 588 | bl_idname = "ifs.bake_hdri" | |
| 589 | bl_label = "Bake Environment" | |
| 590 | ||
| 591 | def execute(self, context): | |
| 592 | settings = context.scene.ifs | |
| 593 | path = bpy.path.abspath(settings.document) | |
| 594 | ||
| 595 | # Prefer what is ON SCREEN, as every other bake does: the panel and the | |
| 596 | # node graph write into the live session's document. | |
| 597 | live = None | |
| 598 | if ifs_viewport.is_running(): | |
| 599 | ifs_viewport.session().apply_pending_graph() | |
| 600 | live = ifs_viewport.session().doc | |
| 601 | if live is None and (not path or not os.path.isfile(path)): | |
| 602 | self.report({'ERROR'}, | |
| 603 | "Start the live viewport, or pick an .ifsjson file first") | |
| 604 | return {'CANCELLED'} | |
| 605 | ||
| 606 | width = settings.hdri_width | |
| 607 | height = max(1, width // 2) | |
| 608 | try: | |
| 609 | shaders = ifs_hdri.build_shader(width, height) | |
| 610 | doc = live if live is not None else ifs_document.load(path, shaders[2]) | |
| 611 | pixels, stats = ifs_hdri.bake_document( | |
| 612 | doc, width, height, dispatches=settings.hdri_dispatches, | |
| 613 | frame=context.scene.frame_current, shaders=shaders) | |
| 614 | image = ifs_hdri.to_image(pixels) | |
| 615 | except Exception as ex: | |
| 616 | traceback.print_exc() | |
| 617 | self.report({'ERROR'}, f"{type(ex).__name__}: {ex}") | |
| 618 | return {'CANCELLED'} | |
| 619 | ||
| 620 | if settings.hdri_set_world: | |
| 621 | _wire_world(image) | |
| 622 | ||
| 623 | source = "live viewport" if live is not None else os.path.basename(path) | |
| 624 | settings.status = (f"Baked {width}x{height} environment from the {source} " | |
| 625 | f"(frame {stats['frame']})") | |
| 626 | self.report({'INFO'}, settings.status) | |
| 627 | return {'FINISHED'} | |
| 628 | ||
| 629 | ||
| 547 | 630 | class IFS_OT_render(Operator): |
| 548 | 631 | """Render the loaded fractal and show it in the Image Editor""" |
| 549 | 632 | |
| … | … | @@ -666,6 +749,13 @@ class IFS_PT_main(Panel): |
| 666 | 749 | box.prop(settings, "points_emission") |
| 667 | 750 | box.operator("ifs.emit_points", icon='OUTLINER_OB_POINTCLOUD') |
| 668 | 751 | |
| 752 | box = layout.box() | |
| 753 | box.label(text="Environment (lights the scene)") | |
| 754 | box.prop(settings, "hdri_width") | |
| 755 | box.prop(settings, "hdri_dispatches") | |
| 756 | box.prop(settings, "hdri_set_world") | |
| 757 | box.operator("ifs.bake_hdri", icon='WORLD') | |
| 758 | ||
| 669 | 759 | |
| 670 | 760 | class IFS_PT_iterators(Panel): |
| 671 | 761 | bl_label = "Iterators" |
| … | … | @@ -725,6 +815,7 @@ _CLASSES = ( |
| 725 | 815 | IFS_OT_viewport, |
| 726 | 816 | IFS_OT_bake_volume, |
| 727 | 817 | IFS_OT_emit_points, |
| 818 | IFS_OT_bake_hdri, | |
| 728 | 819 | IFS_UL_iterators, |
| 729 | 820 | IFS_PT_main, |
| 730 | 821 | IFS_PT_iterators, |
| … | … | @@ -41,6 +41,7 @@ RUNTIME_FILES = ( |
| 41 | 41 | "ifs_viewport.py", |
| 42 | 42 | "ifs_volume.py", |
| 43 | 43 | "ifs_points.py", |
| 44 | "ifs_hdri.py", | |
| 44 | 45 | "ifs_splat.py", |
| 45 | 46 | "ifs_nodes.py", |
| 46 | 47 | ) |
| … | … | @@ -0,0 +1,244 @@ |
| 1 | """ | |
| 2 | Bakes the fractal into an equirectangular environment map. | |
| 3 | ||
| 4 | The fourth way of getting a flame out of the chaos game, and the one with the | |
| 5 | fewest compromises — because the problem every other route fights simply does not | |
| 6 | arise here. | |
| 7 | ||
| 8 | An environment texture is **infinitely far away**. There is nothing to be occluded | |
| 9 | by, so there is no depth compositing, no per-pixel occlusion question, and none of | |
| 10 | the dithering that a cube sitting inside a nebula produces in the screen-space | |
| 11 | overlay. Blender also lights the scene from it for free, in both EEVEE and Cycles, | |
| 12 | which the overlay can never do. | |
| 13 | ||
| 14 | ## Why this is a small change | |
| 15 | ||
| 16 | It reuses everything. `ifs_layout`'s equirect mode swaps only the PROJECTION — | |
| 17 | each sample's direction from a centre, mapped to lat/long — and plots into the | |
| 18 | ordinary 2D histogram. The drain, the auto-exposure and the tonemap are the same | |
| 19 | code the viewport and the still render use, which is the point: the HDRI has to | |
| 20 | look like the fractal, not like a third renderer that resembles it. | |
| 21 | ||
| 22 | ## Dynamic range — measured, and NOT what I first assumed | |
| 23 | ||
| 24 | The image is float, but the values are NOT above 1.0: the shared tonemap ends with | |
| 25 | `clamp(rgb, 0.0, 1.0)` (ifs_viewport, the last line of the fragment shader), so an | |
| 26 | environment bake is bounded like every other output of that shader. | |
| 27 | ||
| 28 | Measured on nebula_sample at 1024x512: mean 0.0154, p99 0.105, max 1.0, and | |
| 29 | essentially 0% of pixels sitting AT 1.0. So the clamp is barely binding — the | |
| 30 | range inside [0,1] is doing the work, and nothing is blowing out. | |
| 31 | ||
| 32 | That costs less than it sounds for lighting, because the World's Strength input | |
| 33 | multiplies the whole map: absolute brightness is recoverable, and the RATIO | |
| 34 | between a bright core and faint haze — which is what actually shapes the light — | |
| 35 | survives the clamp intact. Genuine >1 headroom would mean an unclamped path | |
| 36 | through a tonemap shared with the viewport, which is a deliberate change to make | |
| 37 | on purpose rather than a side effect of adding an HDRI. | |
| 38 | ||
| 39 | ## Animation | |
| 40 | ||
| 41 | `seed` is derived from the FRAME, not from a dispatch counter. That is | |
| 42 | load-bearing for animation: the kernel hashes its seed, so a counter that advanced | |
| 43 | between renders would give a different sample set every time a frame was | |
| 44 | re-rendered, and an animation would boil with noise that changes on every | |
| 45 | re-render. Frame N must bake identically however many times it is baked. (The | |
| 46 | viewport has the opposite requirement and the opposite bug history — see the | |
| 47 | `dispatch_cnt` note in ifs_viewport.) | |
| 48 | """ | |
| 49 | ||
| 50 | from __future__ import annotations | |
| 51 | ||
| 52 | import gpu | |
| 53 | import numpy as np | |
| 54 | ||
| 55 | import ifs_host | |
| 56 | import ifs_render | |
| 57 | ||
| 58 | # 2:1 is the equirectangular convention: u spans 360 degrees of azimuth, v spans | |
| 59 | # 180 of elevation, so square pixels need twice the width. | |
| 60 | DEFAULT_WIDTH = 2048 | |
| 61 | DEFAULT_HEIGHT = 1024 | |
| 62 | ||
| 63 | ||
| 64 | def build_shader(width, height, hist_scale=None): | |
| 65 | """Compiles the equirect variant of the kernel plus its drain.""" | |
| 66 | if hist_scale is None: | |
| 67 | hist_scale = ifs_render.DEFAULT_HIST_SCALE | |
| 68 | return ifs_render._build_shaders(width, height, hist_scale, equirect=True) | |
| 69 | ||
| 70 | ||
| 71 | def _document_buffers(doc): | |
| 72 | """The five per-document UBOs, as the other bake modes build them.""" | |
| 73 | return { | |
| 74 | 'settings': gpu.types.GPUUniformBuf( | |
| 75 | ifs_host.pack_settings(doc.camera, doc.settings)), | |
| 76 | 'iterators_buf': gpu.types.GPUUniformBuf( | |
| 77 | ifs_host.pack_iterators(doc.iterators)), | |
| 78 | 'palette_buf': gpu.types.GPUUniformBuf( | |
| 79 | ifs_host.pack_palette(doc.palette(doc.settings.gradient_resolution))), | |
| 80 | 'real_params_buf': gpu.types.GPUUniformBuf( | |
| 81 | ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators))), | |
| 82 | 'vec3_params_buf': gpu.types.GPUUniformBuf( | |
| 83 | ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators))), | |
| 84 | } | |
| 85 | ||
| 86 | ||
| 87 | def seed_for_frame(frame): | |
| 88 | """ | |
| 89 | A stable per-frame seed. | |
| 90 | ||
| 91 | Any hash would do; what matters is that it is a pure function of the frame. | |
| 92 | Deriving it from a running counter is what makes an animation boil — the same | |
| 93 | frame re-rendered would draw a different sample set. The multiplier is an odd | |
| 94 | constant so consecutive frames are far apart in the kernel's hash. | |
| 95 | """ | |
| 96 | return (int(frame) * 0x9E3779B1 + 0x85EBCA6B) & 0x7FFFFFFF | |
| 97 | ||
| 98 | ||
| 99 | def bake_document(doc, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT, | |
| 100 | dispatches=200, invocation_iters=500, frame=1, | |
| 101 | centre=(0.0, 0.0, 0.0), brightness=None, shaders=None): | |
| 102 | """ | |
| 103 | Bakes an ALREADY-LOADED document to an equirectangular float image. | |
| 104 | ||
| 105 | Takes the document rather than a path for the same reason `bake_document` and | |
| 106 | `emit_document` do: the panel and the node graph write into the live session's | |
| 107 | document, and anything reloading from disk would silently discard every edit | |
| 108 | made inside Blender. | |
| 109 | ||
| 110 | Returns `(pixels, stats)` — pixels (height, width, 4) float32, ROW 0 AT THE | |
| 111 | BOTTOM, which is Blender's image convention and saves a flip on the way in. | |
| 112 | Values ARE bounded to [0,1] by the shared tonemap; see the dynamic-range note | |
| 113 | above for why that costs less than it sounds. | |
| 114 | """ | |
| 115 | gpu.init() | |
| 116 | if shaders is None: | |
| 117 | shaders = build_shader(width, height) | |
| 118 | kernel, drain, _library = shaders | |
| 119 | ||
| 120 | ubos = _document_buffers(doc) | |
| 121 | if brightness is None: | |
| 122 | brightness = doc.brightness * 8.0 | |
| 123 | ||
| 124 | alias = ifs_host.build_alias_tables(doc.iterators) | |
| 125 | n = len(doc.iterators) | |
| 126 | alias_tex = gpu.types.GPUTexture( | |
| 127 | (n, n), format='RGBA32F', | |
| 128 | data=gpu.types.Buffer('FLOAT', len(alias), alias)) | |
| 129 | ||
| 130 | histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI') | |
| 131 | histogram.clear(format='UINT', value=(0, 0, 0, 0)) | |
| 132 | accum = gpu.types.GPUTexture((width, height), format='RGBA32F') | |
| 133 | accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) | |
| 134 | rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W | |
| 135 | state = gpu.types.GPUTexture( | |
| 136 | (ifs_render.STATE_W * 2, rows), format='RGBA32F') | |
| 137 | state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) | |
| 138 | ||
| 139 | seed = seed_for_frame(frame) | |
| 140 | for index in range(dispatches): | |
| 141 | frame_ubo = gpu.types.GPUUniformBuf(ifs_host.pack_frame( | |
| 142 | seed=seed, width=width, height=height, dispatch_cnt=index, | |
| 143 | reset_points_state=1 if index == 0 else 0, | |
| 144 | invocation_iters=invocation_iters)) | |
| 145 | kernel.bind() | |
| 146 | kernel.image('histogram_img', histogram) | |
| 147 | kernel.image('state_img', state) | |
| 148 | kernel.uniform_sampler('alias_tables_tex', alias_tex) | |
| 149 | kernel.uniform_float('equirect_centre', centre) | |
| 150 | kernel.uniform_block('frame', frame_ubo) | |
| 151 | for name, ubo in ubos.items(): | |
| 152 | kernel.uniform_block(name, ubo) | |
| 153 | gpu.compute.dispatch(kernel, ifs_render.WORKGROUPS, 1, 1) | |
| 154 | ||
| 155 | drain.bind() | |
| 156 | drain.image('histogram_img', histogram) | |
| 157 | drain.image('accum_img', accum) | |
| 158 | drain.uniform_int('out_size', (width, height)) | |
| 159 | gpu.compute.dispatch(drain, (width + 7) // 8, (height + 7) // 8, 1) | |
| 160 | ||
| 161 | pixels = _tonemap(accum, width, height, brightness) | |
| 162 | lit = float((pixels[:, :, :3].sum(axis=2) > 1e-4).mean()) | |
| 163 | return pixels, { | |
| 164 | "width": width, | |
| 165 | "height": height, | |
| 166 | "dispatches": dispatches, | |
| 167 | "frame": frame, | |
| 168 | "seed": seed, | |
| 169 | "lit_fraction": lit, | |
| 170 | "peak": float(pixels[:, :, :3].max()), | |
| 171 | } | |
| 172 | ||
| 173 | ||
| 174 | def _tonemap(accum, width, height, brightness): | |
| 175 | """ | |
| 176 | The viewport's own auto-exposure and tonemap, rendered offscreen. | |
| 177 | ||
| 178 | Imported lazily because `ifs_viewport` pulls in `bpy`, and everything above | |
| 179 | this line runs without it. | |
| 180 | """ | |
| 181 | from gpu_extras.batch import batch_for_shader | |
| 182 | import ifs_viewport | |
| 183 | ||
| 184 | maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI') | |
| 185 | maxbuf.clear(format='UINT', value=(0, 0, 0, 0)) | |
| 186 | maxpass = ifs_viewport.build_maxpass() | |
| 187 | maxpass.bind() | |
| 188 | maxpass.image('accum_img', accum) | |
| 189 | maxpass.image('max_img', maxbuf) | |
| 190 | maxpass.uniform_int('out_size', (width, height)) | |
| 191 | gpu.compute.dispatch(maxpass, (width + 7) // 8, (height + 7) // 8, 1) | |
| 192 | ||
| 193 | # The tonemap samples a depth texture even in EMISSION mode. There is no depth | |
| 194 | # here and never will be, so it gets a 1x1 cleared to the FAR value — which the | |
| 195 | # shader reads as "nothing in front", exactly right for an environment. | |
| 196 | far = gpu.types.GPUTexture((1, 1), format='R32UI') | |
| 197 | far.clear(format='UINT', value=(0x3F800000, 0, 0, 0)) | |
| 198 | ||
| 199 | colour = gpu.types.GPUTexture((width, height), format='RGBA32F') | |
| 200 | fb = gpu.types.GPUFrameBuffer(color_slots=(colour,)) | |
| 201 | tonemap = ifs_viewport.build_tonemap() | |
| 202 | batch = batch_for_shader(tonemap, 'TRI_FAN', | |
| 203 | {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) | |
| 204 | with fb.bind(): | |
| 205 | fb.clear(color=(0.0, 0.0, 0.0, 0.0)) | |
| 206 | tonemap.bind() | |
| 207 | tonemap.uniform_sampler('accum_tex', accum) | |
| 208 | tonemap.uniform_sampler('max_tex', maxbuf) | |
| 209 | tonemap.uniform_sampler('depth_tex', far) | |
| 210 | tonemap.uniform_int('out_size', (width, height)) | |
| 211 | tonemap.uniform_float('brightness', brightness) | |
| 212 | tonemap.uniform_int('shading_mode', (0,)) # EMISSION: a sky is light | |
| 213 | tonemap.uniform_float('opacity_gain', 4.0) | |
| 214 | tonemap.uniform_float('relief', 12.0) | |
| 215 | gpu.state.blend_set('NONE') # nothing to blend against | |
| 216 | batch.draw(tonemap) | |
| 217 | raw = fb.read_color(0, 0, width, height, 4, 0, 'FLOAT').to_list() | |
| 218 | return np.array(raw, dtype=np.float32).reshape(height, width, 4) | |
| 219 | ||
| 220 | ||
| 221 | def to_image(pixels, name="IFS Environment"): | |
| 222 | """ | |
| 223 | Writes the bake into a Blender float image, reusing one of the same name. | |
| 224 | ||
| 225 | Reused rather than replaced so that a World already pointing at this image | |
| 226 | keeps working — creating a new datablock every bake would leave the shader | |
| 227 | referencing the previous one and look like the bake did nothing. | |
| 228 | ||
| 229 | Measured at 2048x1024: `foreach_set` is 3.2 ms, so a per-frame update is not | |
| 230 | where the time goes. | |
| 231 | """ | |
| 232 | import bpy | |
| 233 | ||
| 234 | height, width = pixels.shape[:2] | |
| 235 | image = bpy.data.images.get(name) | |
| 236 | if image is None or image.size[0] != width or image.size[1] != height: | |
| 237 | if image is not None: | |
| 238 | bpy.data.images.remove(image) | |
| 239 | image = bpy.data.images.new(name, width=width, height=height, | |
| 240 | float_buffer=True) | |
| 241 | image.colorspace_settings.name = 'Non-Color' | |
| 242 | image.pixels.foreach_set(np.ascontiguousarray(pixels, dtype=np.float32).ravel()) | |
| 243 | image.update() | |
| 244 | return image |
| … | … | @@ -444,6 +444,49 @@ VOLUME_LAYOUT: tuple[Resource, ...] = ( |
| 444 | 444 | # direction - and buys 16384 independent chains instead of 256. |
| 445 | 445 | _WORKGROUP_DRAW = re.compile(r"f_hash4\(seed, gl_WorkGroupID\.x,") |
| 446 | 446 | |
| 447 | _PLOT_EQUIRECT = """ vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity); | |
| 448 | if (color.w == 0.0) | |
| 449 | continue; | |
| 450 | color.xyz *= color.w; | |
| 451 | accumulate_equirect(p.pos.xyz, color);""" | |
| 452 | ||
| 453 | _EQUIRECT_HELPER = """ | |
| 454 | // The fractal as an ENVIRONMENT: each sample's DIRECTION from a centre, mapped to | |
| 455 | // equirectangular lat/long, plotted into the ordinary 2D histogram. | |
| 456 | // | |
| 457 | // This is the smallest of the plot modes because it changes only the projection. | |
| 458 | // The histogram, the drain, the auto-exposure and the tonemap are all reused | |
| 459 | // untouched, so an environment bake goes through exactly the same curve as the | |
| 460 | // viewport and the still render — which is the whole point: it has to look like | |
| 461 | // the fractal, not like a second renderer. | |
| 462 | // | |
| 463 | // It is also the mode with NO depth channel, and that is not a limitation. An | |
| 464 | // environment texture is infinitely far away, so there is nothing to be occluded | |
| 465 | // by and no compositing question at all — the problem the overlay has with a cube | |
| 466 | // inside a nebula simply does not arise here. | |
| 467 | // | |
| 468 | // Blender's world is Z-up and its equirectangular convention runs u around the | |
| 469 | // azimuth and v from the top pole down. | |
| 470 | void accumulate_equirect(vec3 pos, vec4 color) | |
| 471 | { | |
| 472 | \tvec3 d = pos - equirect_centre; | |
| 473 | \tfloat len = length(d); | |
| 474 | \tif (len < 1e-9) | |
| 475 | \t\treturn; // a sample AT the centre has no direction | |
| 476 | \td /= len; | |
| 477 | ||
| 478 | \tconst float TWO_PI = 6.28318530717958647692; | |
| 479 | \tfloat u = atan(d.y, d.x) / TWO_PI + 0.5; | |
| 480 | \tfloat v = acos(clamp(d.z, -1.0, 1.0)) / 3.14159265358979323846; | |
| 481 | ||
| 482 | \t// Bare `width`/`height`: _FRAME_DEFINES aliases them to frame.width/frame.height, | |
| 483 | \t// so spelling the struct out here would expand to frame.frame.width. | |
| 484 | \tivec2 proj = ivec2(u * float(width), v * float(height)); | |
| 485 | \tproj = clamp(proj, ivec2(0), ivec2(width - 1, height - 1)); | |
| 486 | \taccumulate_hit(proj, color); | |
| 487 | } | |
| 488 | """ | |
| 489 | ||
| 447 | 490 | _PLOT_POINTS = """ vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity); |
| 448 | 491 | if (color.w == 0.0) |
| 449 | 492 | continue; |
| … | … | @@ -504,7 +547,8 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 504 | 547 | state_width: int = 256, |
| 505 | 548 | volume: bool = False, |
| 506 | 549 | depth: bool = False, |
| 507 | points: bool = False) -> tuple[str, str, RewriteReport]: | |
| 550 | points: bool = False, | |
| 551 | equirect: bool = False) -> tuple[str, str, RewriteReport]: | |
| 508 | 552 | """ |
| 509 | 553 | Rewrites an engine kernel onto the Blender resource model. |
| 510 | 554 | |
| … | … | @@ -524,8 +568,12 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 524 | 568 | if volume and depth: |
| 525 | 569 | raise ValueError("depth is a 2D-projection concept; it has no meaning in " |
| 526 | 570 | "volume mode, which never projects to a screen pixel") |
| 571 | if equirect and depth: | |
| 572 | raise ValueError("depth has no meaning in equirect mode: an environment is " | |
| 573 | "infinitely far away and is never occluded") | |
| 527 | 574 | chosen = [name for name, on in |
| 528 | (("volume", volume), ("points", points), ("depth", depth)) if on] | |
| 575 | (("volume", volume), ("points", points), ("equirect", equirect), | |
| 576 | ("depth", depth)) if on] | |
| 529 | 577 | if len(chosen) > 1: |
| 530 | 578 | raise ValueError( |
| 531 | 579 | f"{' and '.join(chosen)} cannot be combined: each replaces the plot " |
| … | … | @@ -570,6 +618,13 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 570 | 618 | out = sub(_WORKGROUP_DRAW, "f_hash4(seed, gl_GlobalInvocationID.x,", |
| 571 | 619 | "draw the chaos game per invocation, not per workgroup", |
| 572 | 620 | out, expected=3) |
| 621 | elif equirect: | |
| 622 | # Keeps accumulate_hit — equirect only changes WHERE a sample lands, not | |
| 623 | # how it accumulates — and adds the direction-to-lat/long helper beside it. | |
| 624 | out = sub(_PLOT_BLOCK, lambda _m: _PLOT_EQUIRECT, | |
| 625 | "replace the projection block with an equirect plot", out) | |
| 626 | out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW + _EQUIRECT_HELPER, | |
| 627 | "keep accumulate_hit and add accumulate_equirect", out) | |
| 573 | 628 | elif depth: |
| 574 | 629 | out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_DEPTH, |
| 575 | 630 | "rewrite accumulate_hit with depth", out) |
| … | … | @@ -195,12 +195,18 @@ void main() |
| 195 | 195 | """ |
| 196 | 196 | |
| 197 | 197 | |
| 198 | def _build_shaders(width, height, hist_scale, depth=False): | |
| 198 | def _build_shaders(width, height, hist_scale, depth=False, equirect=False): | |
| 199 | 199 | """ |
| 200 | 200 | `depth` adds the nearest-sample depth image the live overlay needs to be occluded |
| 201 | 201 | by scene geometry. It is off for the still path, which has no scene to be behind |
| 202 | 202 | — and a declared-but-unused image would still consume one of Blender's eight |
| 203 | 203 | slots, so this cannot be left permanently on. |
| 204 | ||
| 205 | `equirect` swaps the perspective projection for a direction-to-lat/long one, so | |
| 206 | the same histogram becomes an environment map. It touches nothing downstream: | |
| 207 | the drain, the auto-exposure and the tonemap are shared, which is what makes an | |
| 208 | HDRI bake go through the SAME curve as the viewport rather than being a second | |
| 209 | renderer that merely resembles it. | |
| 204 | 210 | """ |
| 205 | 211 | library = load_library(os.path.join(REPO, "Transforms"), |
| 206 | 212 | os.path.join(REPO, "WpfDisplay", "Library", "Includes")) |
| … | … | @@ -209,7 +215,8 @@ def _build_shaders(width, height, hist_scale, depth=False): |
| 209 | 215 | with open(template_path, "r", encoding="utf-8-sig") as handle: |
| 210 | 216 | kernel = generate_kernel(handle.read(), library) |
| 211 | 217 | kernel, _ = apply_portability_fixes(kernel) |
| 212 | body, typedefs, report = rewrite(kernel, hist_scale, STATE_W, depth=depth) | |
| 218 | body, typedefs, report = rewrite(kernel, hist_scale, STATE_W, depth=depth, | |
| 219 | equirect=equirect) | |
| 213 | 220 | if not report.ok: |
| 214 | 221 | raise RuntimeError("kernel rewrite incomplete: " + "; ".join(report.missing)) |
| 215 | 222 | |
| … | … | @@ -225,6 +232,8 @@ def _build_shaders(width, height, hist_scale, depth=False): |
| 225 | 232 | # limit (see the add-on's known gaps). Fine on OpenGL; this moves into a UBO |
| 226 | 233 | # with the rest when Vulkan or Metal is attempted. |
| 227 | 234 | info.push_constant('MAT4', 'blender_view_proj') |
| 235 | if equirect: | |
| 236 | info.push_constant('VEC3', 'equirect_centre') | |
| 228 | 237 | info.sampler(0, 'FLOAT_2D', 'alias_tables_tex') |
| 229 | 238 | info.uniform_buf(0, 'FrameParams', 'frame') |
| 230 | 239 | info.uniform_buf(1, 'SettingsParams', 'settings') |