OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 40f5895

Shading modes for the viewport: emission, flat, metal
A display choice, not a change to the fractal - all three are tonemap-only, so switching never restarts accumulation. - **Emission** - the flame as light, added to the scene. The physically honest one: a fractal flame IS emission, and this is what the histogram measures. - **Flat** - unlit palette colour composited over the scene, so it reads as a diagram rather than a light source. - **Metal** - a specular BRDF over a pseudo-normal. Metal needs saying plainly: a density histogram has no surfaces and no normals, so there is nothing to be normal TO. The normal is invented by treating tonemapped log-density as a heightfield and taking central differences, with `relief` controlling how pronounced that fiction is. It is stylistic and makes no claim to be physical. Blend mode follows the shading mode - emission adds, the shaded modes composite alpha-over. That matters because it is the same trap as before: alpha-over needs coverage, and raw tonemapped density is ~0.03 across a nebula's body, which is invisible. So the composited modes get an `opacity_gain` slider rather than a number I would have had to guess. Verified: the shader compiles with all three branches, each mode reaches the session with the right index, the gains sync, and switching leaves dirty_params False so the image keeps accumulating. Also confirms the brightness seeding from the last commit - nebula_sample now arrives at 116.4 rather than the property default. How the three actually LOOK is a judgement call for a real viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40f58955f8c66bf31ad5298ceda6a118c88e1dd2
by plankatron <fleeky@gmail.com> · 2026-08-16T23:12:16.000Z

Changed files (2) +103 -9

blender/__init__.py +27 -0
……@@ -144,6 +144,28 @@ class IFSSceneSettings(PropertyGroup):
144144 dispatches: IntProperty(
145145 name="Dispatches", default=132, min=1, max=100000,
146146 description="Batches of 16384 x 500 iterations. More is cleaner and slower")
147 # A fractal flame is a density histogram, so only EMISSION is physically
148 # honest - the others invent a surface that is not there. All three are
149 # tonemap-only: they change how the accumulator is displayed, never the
150 # attractor, so switching never restarts accumulation.
151 shading_mode: EnumProperty(
152 name="Shading",
153 items=[
154 ('EMISSION', "Emission", "The flame as light, added to the scene"),
155 ('FLAT', "Flat", "Unlit palette colour composited over the scene"),
156 ('METAL', "Metal", "Specular shading from the density gradient"),
157 ],
158 default='EMISSION', update=_touch('tonemap'))
159 opacity_gain: FloatProperty(
160 name="Opacity", default=4.0, min=0.1, soft_max=32.0,
161 description="Coverage for the composited modes. Tonemapped density is "
162 "~0.03 across a nebula, so it needs lifting to be visible",
163 update=_touch('tonemap'))
164 relief: FloatProperty(
165 name="Relief", default=12.0, min=0.0, soft_max=64.0,
166 description="How pronounced the fake surface is in Metal shading",
167 update=_touch('tonemap'))
168
147169 # Tonemap only - the accumulated samples stay valid, so dragging this does not
148170 # restart the render.
149171 brightness: FloatProperty(name="Brightness", default=8.0, min=0.01,
……@@ -377,6 +399,11 @@ class IFS_PT_main(Panel):
377399
378400 box = layout.box()
379401 box.label(text="Live Viewport")
402 box.prop(settings, "shading_mode", text="")
403 if settings.shading_mode != 'EMISSION':
404 box.prop(settings, "opacity_gain")
405 if settings.shading_mode == 'METAL':
406 box.prop(settings, "relief")
380407 box.prop(settings, "iters_per_frame")
381408 running = ifs_viewport.is_running()
382409 box.operator("ifs.toggle_viewport",
blender/ifs_viewport.py +76 -9
……@@ -59,18 +59,72 @@ void main()
5959# The engine's curve: log density, colour averaged by density (RendererGL's
6060# tonemap). Kept in one place so the still and live paths cannot drift.
6161_TONEMAP_FRAG = """
62// Shading modes. A fractal flame is a density histogram, not geometry - there are
63// no surfaces and no normals - so anything other than EMISSION has to invent them.
64// EMISSION is the physically honest one; the others are stylistic.
65#define MODE_EMISSION 0
66#define MODE_FLAT 1
67#define MODE_METAL 2
68
69float density_at(ivec2 c)
70{
71 return texelFetch(accum_tex, clamp(c, ivec2(0), out_size - 1), 0).w;
72}
73
74float luminance_at(ivec2 c, float peak)
75{
76 return log(1.0 + brightness * density_at(c) / peak) / log(10.0);
77}
78
6279void main()
6380{
6481 ivec2 c = ivec2(uv * vec2(out_size));
6582 vec4 h = texelFetch(accum_tex, c, 0);
6683 float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0);
6784 float d = max(h.w, 1e-12);
68 // ADDITIVE, because a flame is EMISSION. Alpha-over needed an alpha, and the
69 // only sensible one - the tonemapped density - is ~0.03 across a nebula's body,
70 // which multiplied an already-tonemapped colour into invisibility. Adding light
71 // leaves empty space untouched (rgb 0 adds nothing) AND keeps full brightness.
72 float s = log(1.0 + brightness * h.w / peak) / log(10.0) / d;
73 fragColor = vec4(clamp(h.rgb * s, 0.0, 1.0), 1.0);
85 float lum = log(1.0 + brightness * h.w / peak) / log(10.0);
86
87 // Density-weighted average palette colour, the engine's own colouring.
88 vec3 base = clamp(h.rgb * (lum / d), 0.0, 1.0);
89
90 if (shading_mode == MODE_EMISSION)
91 {
92 // Added to the scene, so alpha is unused by ADDITIVE_PREMULT.
93 fragColor = vec4(base, 1.0);
94 return;
95 }
96
97 // Alpha-over modes need visible coverage from a value that is ~0.03 across a
98 // nebula, so it is lifted by a user-controlled gain rather than used raw.
99 float a = clamp(lum * opacity_gain, 0.0, 1.0);
100
101 if (shading_mode == MODE_FLAT)
102 {
103 // Unlit: the palette colour at full strength, so it reads as a diagram.
104 vec3 flat_rgb = h.w > 0.0 ? clamp(h.rgb / d, 0.0, 1.0) : vec3(0.0);
105 fragColor = vec4(flat_rgb, a);
106 return;
107 }
108
109 // MODE_METAL: treat tonemapped log-density as a heightfield and light it.
110 // Central differences give a gradient; the z term sets how pronounced the
111 // relief is. Purely stylistic - there is no real geometry to be normal to.
112 float hx = luminance_at(c + ivec2(1, 0), peak) - luminance_at(c - ivec2(1, 0), peak);
113 float hy = luminance_at(c + ivec2(0, 1), peak) - luminance_at(c - ivec2(0, 1), peak);
114 vec3 n = normalize(vec3(-hx * relief, -hy * relief, 1.0));
115
116 const vec3 light = normalize(vec3(-0.4, 0.5, 0.75));
117 vec3 view = vec3(0.0, 0.0, 1.0);
118 vec3 half_v = normalize(light + view);
119
120 float diffuse = max(dot(n, light), 0.0);
121 float spec = pow(max(dot(n, half_v), 0.0), 48.0);
122 float fresnel = pow(1.0 - max(dot(n, view), 0.0), 3.0);
123
124 // Metal: little diffuse, tinted specular, a rim from the fresnel term.
125 vec3 tint = h.w > 0.0 ? clamp(h.rgb / d, 0.0, 1.0) : vec3(0.5);
126 vec3 rgb = tint * (0.12 + 0.25 * diffuse) + tint * spec * 1.6 + tint * fresnel * 0.5;
127 fragColor = vec4(clamp(rgb, 0.0, 1.0), a);
74128}
75129"""
76130
……@@ -98,6 +152,9 @@ class Session:
98152
99153 self.doc = ifs_document.load(document_path, self.library)
100154 self.brightness = self.doc.brightness * 8.0
155 self.shading_mode = 0
156 self.opacity_gain = 4.0
157 self.relief = 12.0
101158
102159 # What needs rebuilding before the next dispatch. Property callbacks fire
103160 # continuously while a slider is dragged, so they only set flags here and
……@@ -129,6 +186,8 @@ class Session:
129186 elif kind != 'tonemap':
130187 raise ValueError(f"unknown dirty kind {kind!r}")
131188
189 SHADING_MODES = {'EMISSION': 0, 'FLAT': 1, 'METAL': 2}
190
132191 def sync_from_scene(self, settings):
133192 """
134193 Copies the panel's values onto the document, flagging what changed.
……@@ -138,6 +197,9 @@ class Session:
138197 property default of 8 on the first redraw and the fractal renders dim.
139198 """
140199 self.brightness = settings.brightness
200 self.shading_mode = self.SHADING_MODES.get(settings.shading_mode, 0)
201 self.opacity_gain = settings.opacity_gain
202 self.relief = settings.relief
141203 for item, iterator in zip(settings.iterators, self.doc.iterators):
142204 iterator.opacity = item.opacity
143205 iterator.color_index = item.color_index
……@@ -191,6 +253,9 @@ class Session:
191253 info.sampler(1, 'UINT_2D', "max_tex")
192254 info.push_constant('IVEC2', "out_size")
193255 info.push_constant('FLOAT', "brightness")
256 info.push_constant('INT', "shading_mode")
257 info.push_constant('FLOAT', "opacity_gain")
258 info.push_constant('FLOAT', "relief")
194259 info.vertex_source(_TONEMAP_VERT)
195260 info.fragment_source(_TONEMAP_FRAG)
196261 return gpu.shader.create_from_info(info)
……@@ -331,9 +396,11 @@ class Session:
331396 self.tonemap.uniform_sampler('max_tex', self.maxbuf)
332397 self.tonemap.uniform_int('out_size', (self.width, self.height))
333398 self.tonemap.uniform_float('brightness', self.brightness)
334 # ADDITIVE_PREMULT is dst = src + dst: the fractal adds light to whatever
335 # the viewport already drew, so the scene stays visible underneath.
336 gpu.state.blend_set('ADDITIVE_PREMULT')
399 self.tonemap.uniform_int('shading_mode', (self.shading_mode,))
400 self.tonemap.uniform_float('opacity_gain', self.opacity_gain)
401 self.tonemap.uniform_float('relief', self.relief)
402 # Emission ADDS light; the shaded modes are surfaces and composite over.
403 gpu.state.blend_set('ADDITIVE_PREMULT' if self.shading_mode == 0 else 'ALPHA')
337404 self.batch.draw(self.tonemap)
338405 gpu.state.blend_set('NONE') # leave the viewport's state as we found it
339406