OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 622db6c

Bake the fractal as an equirectangular environment map
The fourth way out of the chaos game, and the one that dodges the problem every other route fights. An environment texture is infinitely far away: nothing occludes it, so there is no depth compositing, no per-pixel occlusion question, and none of the dithering a cube inside a nebula produces in the overlay. Blender also lights the scene from it, in EEVEE and Cycles, which the overlay never can. It is small because it reuses everything. `equirect` mode swaps only the PROJECTION — each sample's direction from a centre, mapped to lat/long — and plots into the ordinary 2D histogram. The drain, auto-exposure and tonemap are the same code the viewport and still render use, which is the point: it has to look like the fractal, not like a third renderer that resembles it. ANIMATION was designed in rather than bolted on. The seed is a pure function of the FRAME, not a running counter. The kernel hashes its seed, so a counter that advanced between renders would give a different sample set each time a frame was re-rendered and the animation would boil. Verified: same frame byte-identical, different frame different. This is the same class of bug as the fly-mode `dispatch_cnt` reset in issue #6, in the opposite direction. Measured at 1024x512, 60 dispatches: 0.60s, full spherical coverage (100% of rows and columns lit). Pushing pixels into a Blender Image is 3.2ms at 2048x1024, so per-frame updating is not where the time goes. TWO CORRECTIONS TO MY OWN CLAIMS, both caught by measuring: * I wrote that the bake keeps HDR headroom above 1.0. It does not — the shared tonemap ends in `clamp(rgb, 0.0, 1.0)`. Measured: mean 0.0154, p99 0.105, max 1.0, ~0% of pixels AT 1.0, so the clamp is barely binding and the range inside [0,1] does the work. World Strength recovers absolute brightness and the ratio that shapes the light survives intact. Real >1 headroom means an unclamped path through a shader shared with the viewport — a deliberate change, not a side effect of adding an HDRI. Docstring corrected. * The helper first spelled `frame.width`, which expands to `frame.frame.width` because _FRAME_DEFINES aliases the bare names. Shader compile error, fixed to the bare form the rest of the kernel uses. Operator, panel and World wiring included; `hdri_set_world` rebuilds the World node tree rather than hunting for an existing Environment Texture to repoint, which is how you end up wired to a node the user was using for something else. layout 9/9, points_layout 8/8, bake_orientation 7/7, volume_axes 5/5, depth_composite 3/3, points_emit 10/10 — the kernel change touches the shared rewrite, so those are the gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
622db6c0dee578d4ce7ba9dff9b6be52aa40edcf
by plankatron <fleeky@gmail.com> · 2026-08-18T19:04:01.000Z

Changed files (5) +404 -4

blender/__init__.py +91 -0
……@@ -48,6 +48,7 @@ if _HERE not in sys.path:
4848
4949import ifs_codegen
5050import ifs_document
51import ifs_hdri
5152import ifs_points
5253import ifs_render
5354import ifs_viewport
……@@ -214,6 +215,19 @@ class IFSSceneSettings(PropertyGroup):
214215 description="Emission strength of the point material. Unlike the volume's "
215216 "exposure this needs no derivation - a point is a surface the "
216217 "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")
217231 iterators: CollectionProperty(type=IFSIteratorItem)
218232 active_iterator: IntProperty(default=0)
219233 status: StringProperty(default="")
……@@ -544,6 +558,75 @@ class IFS_OT_emit_points(Operator):
544558 return {'FINISHED'}
545559
546560
561def _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
585class 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
547630class IFS_OT_render(Operator):
548631 """Render the loaded fractal and show it in the Image Editor"""
549632
……@@ -666,6 +749,13 @@ class IFS_PT_main(Panel):
666749 box.prop(settings, "points_emission")
667750 box.operator("ifs.emit_points", icon='OUTLINER_OB_POINTCLOUD')
668751
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
669759
670760class IFS_PT_iterators(Panel):
671761 bl_label = "Iterators"
……@@ -725,6 +815,7 @@ _CLASSES = (
725815 IFS_OT_viewport,
726816 IFS_OT_bake_volume,
727817 IFS_OT_emit_points,
818 IFS_OT_bake_hdri,
728819 IFS_UL_iterators,
729820 IFS_PT_main,
730821 IFS_PT_iterators,
blender/build_addon.py +1 -0
……@@ -41,6 +41,7 @@ RUNTIME_FILES = (
4141 "ifs_viewport.py",
4242 "ifs_volume.py",
4343 "ifs_points.py",
44 "ifs_hdri.py",
4445 "ifs_splat.py",
4546 "ifs_nodes.py",
4647)
blender/ifs_hdri.py +244 -0
……@@ -0,0 +1,244 @@
1"""
2Bakes the fractal into an equirectangular environment map.
3
4The fourth way of getting a flame out of the chaos game, and the one with the
5fewest compromises — because the problem every other route fights simply does not
6arise here.
7
8An environment texture is **infinitely far away**. There is nothing to be occluded
9by, so there is no depth compositing, no per-pixel occlusion question, and none of
10the dithering that a cube sitting inside a nebula produces in the screen-space
11overlay. Blender also lights the scene from it for free, in both EEVEE and Cycles,
12which the overlay can never do.
13
14## Why this is a small change
15
16It reuses everything. `ifs_layout`'s equirect mode swaps only the PROJECTION —
17each sample's direction from a centre, mapped to lat/long — and plots into the
18ordinary 2D histogram. The drain, the auto-exposure and the tonemap are the same
19code the viewport and the still render use, which is the point: the HDRI has to
20look like the fractal, not like a third renderer that resembles it.
21
22## Dynamic range — measured, and NOT what I first assumed
23
24The 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
26environment bake is bounded like every other output of that shader.
27
28Measured on nebula_sample at 1024x512: mean 0.0154, p99 0.105, max 1.0, and
29essentially 0% of pixels sitting AT 1.0. So the clamp is barely binding — the
30range inside [0,1] is doing the work, and nothing is blowing out.
31
32That costs less than it sounds for lighting, because the World's Strength input
33multiplies the whole map: absolute brightness is recoverable, and the RATIO
34between a bright core and faint haze — which is what actually shapes the light —
35survives the clamp intact. Genuine >1 headroom would mean an unclamped path
36through a tonemap shared with the viewport, which is a deliberate change to make
37on 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
42load-bearing for animation: the kernel hashes its seed, so a counter that advanced
43between renders would give a different sample set every time a frame was
44re-rendered, and an animation would boil with noise that changes on every
45re-render. Frame N must bake identically however many times it is baked. (The
46viewport has the opposite requirement and the opposite bug history — see the
47`dispatch_cnt` note in ifs_viewport.)
48"""
49
50from __future__ import annotations
51
52import gpu
53import numpy as np
54
55import ifs_host
56import 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.
60DEFAULT_WIDTH = 2048
61DEFAULT_HEIGHT = 1024
62
63
64def 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
71def _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
87def 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
99def 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
174def _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
221def 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
blender/ifs_layout.py +57 -2
……@@ -444,6 +444,49 @@ VOLUME_LAYOUT: tuple[Resource, ...] = (
444444# direction - and buys 16384 independent chains instead of 256.
445445_WORKGROUP_DRAW = re.compile(r"f_hash4\(seed, gl_WorkGroupID\.x,")
446446
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.
470void 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
447490_PLOT_POINTS = """ vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity);
448491 if (color.w == 0.0)
449492 continue;
……@@ -504,7 +547,8 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
504547 state_width: int = 256,
505548 volume: bool = False,
506549 depth: bool = False,
507 points: bool = False) -> tuple[str, str, RewriteReport]:
550 points: bool = False,
551 equirect: bool = False) -> tuple[str, str, RewriteReport]:
508552 """
509553 Rewrites an engine kernel onto the Blender resource model.
510554
……@@ -524,8 +568,12 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
524568 if volume and depth:
525569 raise ValueError("depth is a 2D-projection concept; it has no meaning in "
526570 "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")
527574 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]
529577 if len(chosen) > 1:
530578 raise ValueError(
531579 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,
570618 out = sub(_WORKGROUP_DRAW, "f_hash4(seed, gl_GlobalInvocationID.x,",
571619 "draw the chaos game per invocation, not per workgroup",
572620 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)
573628 elif depth:
574629 out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_DEPTH,
575630 "rewrite accumulate_hit with depth", out)
blender/ifs_render.py +11 -2
……@@ -195,12 +195,18 @@ void main()
195195"""
196196
197197
198def _build_shaders(width, height, hist_scale, depth=False):
198def _build_shaders(width, height, hist_scale, depth=False, equirect=False):
199199 """
200200 `depth` adds the nearest-sample depth image the live overlay needs to be occluded
201201 by scene geometry. It is off for the still path, which has no scene to be behind
202202 — and a declared-but-unused image would still consume one of Blender's eight
203203 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.
204210 """
205211 library = load_library(os.path.join(REPO, "Transforms"),
206212 os.path.join(REPO, "WpfDisplay", "Library", "Includes"))
……@@ -209,7 +215,8 @@ def _build_shaders(width, height, hist_scale, depth=False):
209215 with open(template_path, "r", encoding="utf-8-sig") as handle:
210216 kernel = generate_kernel(handle.read(), library)
211217 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)
213220 if not report.ok:
214221 raise RuntimeError("kernel rewrite incomplete: " + "; ".join(report.missing))
215222
……@@ -225,6 +232,8 @@ def _build_shaders(width, height, hist_scale, depth=False):
225232 # limit (see the add-on's known gaps). Fine on OpenGL; this moves into a UBO
226233 # with the rest when Vulkan or Metal is attempted.
227234 info.push_constant('MAT4', 'blender_view_proj')
235 if equirect:
236 info.push_constant('VEC3', 'equirect_centre')
228237 info.sampler(0, 'FLOAT_2D', 'alias_tables_tex')
229238 info.uniform_buf(0, 'FrameParams', 'frame')
230239 info.uniform_buf(1, 'SettingsParams', 'settings')