"""
Live progressive rendering into the 3D viewport.
A draw handler dispatches a batch of chaos-game iterations per redraw, drains into
a float accumulator, tonemaps on the GPU and blits the result over the viewport.
Nothing is read back to the CPU in the steady state — the per-frame readback that
a Python tonemap would need is what would make this a slideshow.
Two things make it feel right rather than merely work:
* **The kernel follows the VIEWPORT camera**, not the document's. Orbiting rebuilds
the view-projection from `region_3d`, so you navigate the fractal the way you
navigate a scene.
* **Camera movement invalidates the histogram.** Accumulated samples belong to the
old viewpoint, so they are discarded and accumulation restarts — the engine does
the same thing through `reset_points_state`. While you move you get a noisy
preview; when you stop it resolves. That is inherent to progressive accumulation,
not a defect.
The auto-exposure max is computed on the GPU with `imageAtomicMax` into a 1x1
image, so even the peak density never crosses the bus.
"""
from __future__ import annotations
import math
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
import ifs_document
import ifs_host
import ifs_layout
import ifs_render
# One live session at a time. A second would fight the first for the histogram.
_session = None
_MAXPASS = """
void main()
{
ivec2 c = ivec2(gl_GlobalInvocationID.xy);
if (c.x >= out_size.x || c.y >= out_size.y) return;
float d = imageLoad(accum_img, c).w;
imageAtomicMax(max_img, ivec2(0, 0), uint(d));
}
"""
_TONEMAP_VERT = """
void main()
{
uv = pos * 0.5 + 0.5;
gl_Position = vec4(pos, 0.0, 1.0);
}
"""
# The engine's curve: log density, colour averaged by density (RendererGL's
# tonemap). Kept in one place so the still and live paths cannot drift.
_TONEMAP_FRAG = """
// Shading modes. A fractal flame is a density histogram, not geometry - there are
// no surfaces and no normals - so anything other than EMISSION has to invent them.
// EMISSION is the physically honest one; the others are stylistic.
#define MODE_EMISSION 0
#define MODE_FLAT 1
#define MODE_METAL 2
// The accumulator is `depth_slices` screens tall; this maps a screen pixel into
// the band being drawn. Every sampler read goes through it, so the neighbourhood
// reads that drive relief and the density filter stay inside the same band.
ivec2 sliced(ivec2 c)
{
return ivec2(clamp(c, ivec2(0), out_size - 1)) + ivec2(0, slice * out_size.y);
}
float density_at(ivec2 c)
{
return texelFetch(accum_tex, sliced(c), 0).w;
}
float luminance_at(ivec2 c, float peak)
{
return log(1.0 + brightness * density_at(c) / peak) / log(10.0);
}
void main()
{
ivec2 c = ivec2(uv * vec2(out_size));
// Hand the hardware depth test the nearest sample that landed on this pixel, so
// scene geometry occludes the fractal. Written on EVERY path, before any early
// return - gl_FragDepth is undefined if a branch skips it, and an undefined depth
// against LESS_EQUAL flickers rather than fails cleanly.
//
// A pixel nothing landed on holds 1.0 (the far plane) and contributes no colour,
// so it neither occludes nor is occluded. Note this texture is at the accumulator's
// resolution, which is a half or quarter of the screen, so the occlusion boundary
// is coarser than the geometry that casts it.
// The band's FAR edge, expressed INSIDE the range the fractal occupies. Mapping
// bands onto [0,1] instead put every band beyond any real geometry — window
// depth is nonlinear enough that a scene at distance 5..20 lives in 0.980..0.995
// — so the last band sat at 1.0 and a cube occluded the fractal entirely.
//
// With one band, or before a range has been measured, this falls back to the
// nearest-sample depth, which is what the still path wants.
float dmin = uintBitsToFloat(~texelFetch(range_tex, ivec2(0, 0), 0).x);
float dmax = uintBitsToFloat(texelFetch(range_tex, ivec2(1, 0), 0).x);
gl_FragDepth = (depth_slices > 1 && dmax > dmin)
? dmin + (float(slice + 1) / float(depth_slices)) * (dmax - dmin)
: uintBitsToFloat(texelFetch(depth_tex, c, 0).x);
vec4 h = texelFetch(accum_tex, sliced(c), 0);
float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0);
float d = max(h.w, 1e-12);
float lum = log(1.0 + brightness * h.w / peak) / log(10.0);
// Density-weighted average palette colour, the engine's own colouring.
vec3 base = clamp(h.rgb * (lum / d), 0.0, 1.0);
if (shading_mode == MODE_EMISSION)
{
// Added to the scene, so alpha is unused by ADDITIVE_PREMULT.
fragColor = vec4(base, 1.0);
return;
}
// Alpha-over modes need visible coverage from a value that is ~0.03 across a
// nebula, so it is lifted by a user-controlled gain rather than used raw.
float a = clamp(lum * opacity_gain, 0.0, 1.0);
if (shading_mode == MODE_FLAT)
{
// Unlit: the palette colour at full strength, so it reads as a diagram.
vec3 flat_rgb = h.w > 0.0 ? clamp(h.rgb / d, 0.0, 1.0) : vec3(0.0);
fragColor = vec4(flat_rgb, a);
return;
}
// MODE_METAL: treat tonemapped log-density as a heightfield and light it.
// Central differences give a gradient; the z term sets how pronounced the
// relief is. Purely stylistic - there is no real geometry to be normal to.
float hx = luminance_at(c + ivec2(1, 0), peak) - luminance_at(c - ivec2(1, 0), peak);
float hy = luminance_at(c + ivec2(0, 1), peak) - luminance_at(c - ivec2(0, 1), peak);
vec3 n = normalize(vec3(-hx * relief, -hy * relief, 1.0));
const vec3 light = normalize(vec3(-0.4, 0.5, 0.75));
vec3 view = vec3(0.0, 0.0, 1.0);
vec3 half_v = normalize(light + view);
float diffuse = max(dot(n, light), 0.0);
float spec = pow(max(dot(n, half_v), 0.0), 48.0);
float fresnel = pow(1.0 - max(dot(n, view), 0.0), 3.0);
// Metal: little diffuse, tinted specular, a rim from the fresnel term.
vec3 tint = h.w > 0.0 ? clamp(h.rgb / d, 0.0, 1.0) : vec3(0.5);
vec3 rgb = tint * (0.12 + 0.25 * diffuse) + tint * spec * 1.6 + tint * fresnel * 0.5;
fragColor = vec4(clamp(rgb, 0.0, 1.0), a);
}
"""
# Both builders are pure, and live at module level rather than on Session so the
# compile gate can build them without a document, a GPU-backed Session or a viewport.
# Compiling these is the only place the gl_FragDepth write and the MAT4 push constant
# are actually checked by a driver.
_DEPTH_RANGE = """
void main()
{
ivec2 c = ivec2(gl_GlobalInvocationID.xy);
if (c.x >= out_size.x || c.y >= out_size.y) return;
uint d = imageLoad(depth_img, c).x;
/* Untouched pixels hold the far plane. Including them would peg the maximum at
1.0 for any frame with empty sky, which is most of them, and the range would
be meaningless. */
if (d >= 0x3F800000u) return;
/* Both texels reduced with MAX, and the minimum stored COMPLEMENTED.
GPUTexture.clear sets every texel to the same value, so a min and a max
cannot both be seeded correctly from one clear — seeded to 1.0 the maximum
can never fall below the far plane, and seeded to 0 the minimum is stuck
there. Since ~x is monotonically decreasing in x, max(~d) == ~min(d), so one
clear to zero seeds both and the reader complements texel 0 back. */
imageAtomicMax(range_img, ivec2(0, 0), ~d);
imageAtomicMax(range_img, ivec2(1, 0), d);
}
"""
def build_depth_range():
"""
Reduces the depth buffer to the fractal's own min and max depth.
Two texels, on the GPU, with no readback — the same discipline the
auto-exposure follows, and for the same reason: a readback here would stall
the pipeline every frame.
"""
info = gpu.types.GPUShaderCreateInfo()
info.local_group_size(8, 8)
rw = {'NO_RESTRICT', 'READ', 'WRITE'}
info.image(0, 'R32UI', 'UINT_2D', 'depth_img', qualifiers={'NO_RESTRICT', 'READ'})
info.image(1, 'R32UI', 'UINT_2D', 'range_img', qualifiers=rw)
info.push_constant('IVEC2', 'out_size')
info.compute_source(_DEPTH_RANGE)
return gpu.shader.create_from_info(info)
def build_maxpass():
info = gpu.types.GPUShaderCreateInfo()
info.local_group_size(8, 8)
rw = {'NO_RESTRICT', 'READ', 'WRITE'}
info.image(0, 'RGBA32F', 'FLOAT_2D', 'accum_img', qualifiers={'NO_RESTRICT', 'READ'})
info.image(1, 'R32UI', 'UINT_2D', 'max_img', qualifiers=rw)
info.push_constant('IVEC2', 'out_size')
info.compute_source(_MAXPASS)
return gpu.shader.create_from_info(info)
def build_tonemap():
vert = gpu.types.GPUStageInterfaceInfo("ifs_tonemap")
vert.smooth('VEC2', "uv")
info = gpu.types.GPUShaderCreateInfo()
info.vertex_in(0, 'VEC2', "pos")
info.vertex_out(vert)
info.fragment_out(0, 'VEC4', "fragColor")
info.sampler(0, 'FLOAT_2D', "accum_tex")
info.sampler(1, 'UINT_2D', "max_tex")
info.sampler(2, 'UINT_2D', "depth_tex")
info.sampler(3, 'UINT_2D', "range_tex")
info.push_constant('IVEC2', "out_size")
info.push_constant('FLOAT', "brightness")
info.push_constant('INT', "shading_mode")
info.push_constant('FLOAT', "opacity_gain")
info.push_constant('FLOAT', "relief")
info.push_constant('INT', "slice")
info.push_constant('INT', "depth_slices")
# Declaring this is what makes writing gl_FragDepth legal; without it the write
# is silently dropped by the shader wrapper.
info.depth_write('ANY')
info.vertex_source(_TONEMAP_VERT)
info.fragment_source(_TONEMAP_FRAG)
return gpu.shader.create_from_info(info)
class Session:
"""Persistent GPU state for one live viewport."""
def __init__(self, document_path, iters_per_frame=400, hist_scale=1024.0):
self.iters_per_frame = iters_per_frame
self.hist_scale = hist_scale
# Depth bands. The accumulator is this many screens tall and each band is
# blitted with its own gl_FragDepth, which is what turns a binary occlusion
# test into an N-step one. 1 restores the old single-buffer behaviour.
#
# 4 by default because the cost is linear in bands and it is not small:
# at 1920x1080 the histogram and accumulator together are 66 MB at 1 band,
# 265 MB at 4 and 531 MB at 8. Four steps is already a large improvement on
# binary; eight is a preference, not a necessity.
self.depth_slices = 4
self.handle = None
self.width = 0
self.height = 0
self.last_view = None
# Two counters, deliberately. `dispatch_cnt` goes into the kernel's hash
# and must NEVER repeat, or a restarted accumulation replays the identical
# sample set. `since_reset` decides when to re-seed the point state.
self.dispatch_count = 0 # monotonic, feeds the RNG
self.since_reset = 0 # zeroed by invalidate()
self.frames = 0
self.kernel, self.drain, self.library = ifs_render._build_shaders(
1, 1, hist_scale, depth=True)
self.maxpass = build_maxpass()
self.depth_range = build_depth_range()
self.tonemap = build_tonemap()
self.batch = batch_for_shader(
self.tonemap, 'TRI_FAN',
{"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
self.doc = ifs_document.load(document_path, self.library)
self.brightness = self.doc.brightness * 8.0
self.shading_mode = 0
self.opacity_gain = 4.0
self.relief = 12.0
# What needs rebuilding before the next dispatch. Property callbacks fire
# continuously while a slider is dragged, so they only set flags here and
# the work happens once per redraw.
self._dirty_params = False
self._dirty_weights = False
# A node graph, when one is bound, becomes the source of truth for the
# iterators. Re-read only when Blender says it changed - walking the tree
# every redraw would be wasted work and would fight the panel sliders.
self.tree_name = None
self._dirty_graph = False
self._build_buffers()
self._resize(64, 64)
def bind_tree(self, name: str | None):
"""Makes a node tree drive this session's iterators."""
self.tree_name = name
self._dirty_graph = name is not None
def resolve_tree(self):
"""
Binds an IFS tree if none is bound yet.
The binding used to happen ONCE, in `start`, against whatever trees already
existed. Build the graph after starting the viewport - the obvious order, since
the panel is right there - and `tree_name` stayed None forever, so the graph
drove nothing and the depsgraph handler could not rescue it either: its first
line returns when `tree_name is None`. Resolving lazily means the graph is
picked up whenever it appears.
"""
if self.tree_name is not None:
return
for tree in bpy.data.node_groups:
if tree.bl_idname == 'IFSNodeTree':
self.bind_tree(tree.name)
return
def apply_pending_graph(self):
"""
Flushes a pending graph edit into the document.
`_apply_graph` otherwise runs only inside `draw`, so a bake triggered before the
next redraw would read a document one edit stale - the graph would look bound
and still not reach the volume.
"""
self.resolve_tree()
self._apply_graph()
def mark_graph_dirty(self):
self._dirty_graph = True
def mark_dirty(self, kind: str):
"""
Flags what a UI edit invalidated.
`tonemap` costs nothing - brightness only changes how the accumulator is
displayed, so the samples stay valid and the image does NOT restart. That
distinction is the difference between a brightness slider that feels
instant and one that re-renders the fractal on every pixel of drag.
`params` and `weights` change the attractor itself, so accumulated samples
no longer belong to it and must be discarded. `weights` additionally
rebuilds the xaos alias texture.
"""
if kind == 'weights':
self._dirty_weights = True
self._dirty_params = True
elif kind == 'params':
self._dirty_params = True
elif kind != 'tonemap':
raise ValueError(f"unknown dirty kind {kind!r}")
SHADING_MODES = {'EMISSION': 0, 'FLAT': 1, 'METAL': 2}
def sync_from_scene(self, settings):
"""
Copies the panel's values onto the document, flagging what changed.
Brightness included - but the panel must have been seeded from the
document first, or this silently replaces a document's 14.55 with the
property default of 8 on the first redraw and the fractal renders dim.
"""
bands = int(getattr(settings, 'depth_bands', self.depth_slices))
if bands != self.depth_slices:
# Changing the band count reshapes both buffers, so force a rebuild the
# same way a resize does rather than leaving stale geometry behind.
self.depth_slices = bands
self.width = self.height = 0
self.brightness = settings.brightness
self.shading_mode = self.SHADING_MODES.get(settings.shading_mode, 0)
self.opacity_gain = settings.opacity_gain
self.relief = settings.relief
# When a node graph is bound it OWNS the iterators. Copying the panel's
# values on every frame would revert each graph edit one frame after it
# landed, which looks exactly like the graph doing nothing at all. The
# display settings above still apply; they are not the graph's business.
if self.tree_name is not None:
return
for item, iterator in zip(settings.iterators, self.doc.iterators):
iterator.opacity = item.opacity
iterator.color_index = item.color_index
iterator.color_speed = item.color_speed
iterator.base_weight = item.base_weight
count = len(iterator.real_params)
iterator.real_params = list(item.real_values[:count])
iterator.vec3_params = [tuple(item.vec3_values[i * 3:i * 3 + 3])
for i in range(len(iterator.vec3_params))]
def _apply_graph(self):
"""Pulls the bound node graph onto the document."""
if not self._dirty_graph or self.tree_name is None:
return
self._dirty_graph = False
tree = bpy.data.node_groups.get(self.tree_name)
if tree is None:
self.tree_name = None
return
import ifs_nodes
try:
ifs_nodes.apply_tree(tree, self.doc, self.library)
except ValueError:
# Iterator count no longer matches - the graph is for another
# document. Unbind rather than render a mismatch.
self.tree_name = None
return
# A graph edit can change topology and weights, so assume the most.
self.mark_dirty('weights')
def _rebuild_dirty(self):
if not self._dirty_params:
return
doc = self.doc
self.ubos['iterators_buf'] = gpu.types.GPUUniformBuf(
ifs_host.pack_iterators(doc.iterators))
self.ubos['real_params_buf'] = gpu.types.GPUUniformBuf(
ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators)))
self.ubos['vec3_params_buf'] = gpu.types.GPUUniformBuf(
ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators)))
if self._dirty_weights:
alias = ifs_host.build_alias_tables(doc.iterators)
n = len(doc.iterators)
self.alias_tex = gpu.types.GPUTexture(
(n, n), format='RGBA32F',
data=gpu.types.Buffer('FLOAT', len(alias), alias))
self._dirty_weights = False
self._dirty_params = False
self.invalidate()
# ------------------------------------------------------------------ buffers
def _build_buffers(self):
doc = self.doc
self.ubos = {
'iterators_buf': gpu.types.GPUUniformBuf(
ifs_host.pack_iterators(doc.iterators)),
'palette_buf': gpu.types.GPUUniformBuf(
ifs_host.pack_palette(doc.palette(doc.settings.gradient_resolution))),
'real_params_buf': gpu.types.GPUUniformBuf(
ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators))),
'vec3_params_buf': gpu.types.GPUUniformBuf(
ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators))),
}
alias = ifs_host.build_alias_tables(doc.iterators)
n = len(doc.iterators)
self.alias_tex = gpu.types.GPUTexture(
(n, n), format='RGBA32F',
data=gpu.types.Buffer('FLOAT', len(alias), alias))
def _resize(self, width, height) -> bool:
"""Returns True if the buffers were rebuilt (which already invalidated)."""
if (width, height) == (self.width, self.height):
return False
self.width, self.height = width, height
bands = max(1, self.depth_slices)
self.histogram = gpu.types.GPUTexture((width * 4, height * bands),
format='R32UI')
self.accum = gpu.types.GPUTexture((width, height * bands), format='RGBA32F')
self.maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
# One depth per pixel, not four - it is a minimum, not an accumulation, so it
# needs no interleaving and no drain.
self.depthbuf = gpu.types.GPUTexture((width, height), format='R32UI')
# Texel 0 = min, texel 1 = max depth of whatever the fractal has touched.
self.rangebuf = gpu.types.GPUTexture((2, 1), format='R32UI')
rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W
self.state = gpu.types.GPUTexture(
(ifs_render.STATE_W * 2, rows), format='RGBA32F')
self.invalidate()
return True
def invalidate(self):
"""Discards accumulated samples. Called when the viewpoint changes."""
self.histogram.clear(format='UINT', value=(0, 0, 0, 0))
self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
self.state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
# Cleared to the FAR plane, not to zero: zero is the near plane, and a pixel
# nothing has landed on yet would occlude the entire scene behind it.
self.depthbuf.clear(format='UINT', value=(ifs_layout.DEPTH_CLEAR, 0, 0, 0))
# dispatch_count is NOT reset: it is hash input, and rewinding it makes
# every restart draw the same points. Under continuous camera motion that
# is a fixed constellation sliding across the screen rather than the
# fractal - which is what fly mode looked like.
self.since_reset = 0
# -------------------------------------------------------------------- camera
def _view_projection(self, region_3d, lens):
"""
The engine's view-projection, from the viewport's current camera.
Blender's own `perspective_matrix` cannot be used directly: the kernel
discards `w >= 0`, which is the opposite of Blender's convention. Building
it from eye/forward/up through the engine's own construction keeps the sign
right instead of fighting it.
"""
world = region_3d.view_matrix.inverted()
eye = world.translation
forward = -Vector(world.col[2][:3]) # Blender cameras look down -Z
up = Vector(world.col[1][:3])
# Horizontal FOV from the viewport's focal length against Blender's 36mm
# sensor. The kernel's projection uses aspect 1 and applies width/height
# itself, so the horizontal angle is the one it wants.
fov = math.degrees(2.0 * math.atan(18.0 / max(lens, 1.0)))
return ifs_render.view_projection(
(eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z),
(up.x, up.y, up.z), fov_degrees=fov, engine_fov_quirk=False,
# IFSEngine's view basis is a HORIZONTAL mirror of Blender's - its
# x_ndc = k*x_view/(-z_view) sends camera-right to screen-left - so an
# overlay tracking Blender's camera must undo that, and only that.
# Orbiting is what exposes it: still images agree at screen centre.
# The DOCUMENT path must NOT flip; it matches IFSEngine, checked
# against ifsrender at 0.611 as-is versus 0.004 mirrored.
flip_x=True)
# How much the view-projection may drift before the accumulation is thrown
# away. Exact comparison restarts on floating-point noise and on Blender's
# smooth-view easing, so the image resamples every frame and never settles -
# which looks like the fractal refusing to sit still.
VIEW_EPSILON = 1e-5
def _view_moved(self, view_proj) -> bool:
if self.last_view is None:
return True
return any(abs(a - b) > self.VIEW_EPSILON
for a, b in zip(view_proj, self.last_view))
# ---------------------------------------------------------------------- draw
# While the camera moves, every frame throws its samples away, so one dispatch
# has to cover the whole image and the result is sparse noise. Rotation shows
# this worst - mouselook changes the view every single frame, where translation
# often pauses. Rendering at a coarser internal resolution during motion puts
# the same budget over a quarter of the pixels, so it stays readable.
#
# Three tiers, because two never reached 1:1. The comment here used to claim full
# resolution returned when the view settled, but STILL_DIVISOR is 2 - so the
# overlay was permanently HALF resolution per axis, a quarter of the pixels,
# scaled up. That was the ceiling on how sharp the viewport could ever look, and
# it also coarsened the depth texture, which is why occlusion edges were blocky.
#
# The middle tier is worth keeping: it gives a readable image within a few frames
# of stopping, where jumping straight to 1:1 would leave a long noisy period. Each
# step resizes and so restarts accumulation, which is the price of the sharper
# grid - the final tier then accumulates indefinitely.
MOTION_DIVISOR = 4
STILL_DIVISOR = 2
FULL_DIVISOR = 1
STILL_FRAMES = 3
SHARPEN_FRAMES = 24
@classmethod
def divisor_for(cls, still_frames):
"""The internal-resolution divisor for a run of unchanged frames."""
if still_frames < cls.STILL_FRAMES:
return cls.MOTION_DIVISOR
if still_frames < cls.SHARPEN_FRAMES:
return cls.STILL_DIVISOR
return cls.FULL_DIVISOR
def draw(self, context):
settings = getattr(context.scene, "ifs", None)
if settings is not None and settings.iterators:
self.sync_from_scene(settings)
self.resolve_tree()
self._apply_graph()
self._rebuild_dirty()
space = context.space_data
view_proj = self._view_projection(space.region_3d, space.lens)
moved = self._view_moved(view_proj)
self._still_frames = 0 if moved else getattr(self, "_still_frames", 0) + 1
divisor = self.divisor_for(self._still_frames)
region = context.region
resized = self._resize(max(region.width // divisor, 16),
max(region.height // divisor, 16))
if moved and not resized:
self.last_view = view_proj
self.invalidate()
elif moved:
self.last_view = view_proj
camera = self.doc.camera
camera.view_proj = view_proj
world = space.region_3d.view_matrix.inverted()
camera.position = (*world.translation, 1.0)
settings_ubo = gpu.types.GPUUniformBuf(
ifs_host.pack_settings(camera, self.doc.settings))
frame = gpu.types.GPUUniformBuf(ifs_host.pack_frame(
seed=0x9E3779B9, width=self.width, height=self.height,
dispatch_cnt=self.dispatch_count,
reset_points_state=1 if self.since_reset == 0 else 0,
invocation_iters=self.iters_per_frame))
# Measure the fractal's depth extent from the previous frame, then band
# over THAT. Cleared to an empty range (min=+far, max=0) so a frame with
# nothing in it reports no range and the kernel falls back to one band.
# Zero seeds BOTH reductions; see the complement note in _DEPTH_RANGE.
self.rangebuf.clear(format='UINT', value=(0, 0, 0, 0))
self.depth_range.bind()
self.depth_range.image('depth_img', self.depthbuf)
self.depth_range.image('range_img', self.rangebuf)
self.depth_range.uniform_int('out_size', (self.width, self.height))
gpu.compute.dispatch(self.depth_range, (self.width + 7) // 8,
(self.height + 7) // 8, 1)
self.kernel.bind()
self.kernel.image('histogram_img', self.histogram)
self.kernel.image('depth_range_img', self.rangebuf)
self.kernel.image('state_img', self.state)
self.kernel.image('depth_img', self.depthbuf)
# Blender's OWN view-projection, taken whole rather than rebuilt: the kernel's
# `view_proj_mat` is the engine's construction (mirrored in x, FOV quirk) and
# its z does not correspond to what Blender's depth buffer holds. Using the
# viewport's matrix means our depth matches the buffer by construction.
self.kernel.uniform_float(
'blender_view_proj',
space.region_3d.window_matrix @ space.region_3d.view_matrix)
self.kernel.uniform_int('depth_slices', (self.depth_slices,))
self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex)
self.kernel.uniform_block('frame', frame)
self.kernel.uniform_block('settings', settings_ubo)
for name, ubo in self.ubos.items():
self.kernel.uniform_block(name, ubo)
gpu.compute.dispatch(self.kernel, ifs_render.WORKGROUPS, 1, 1)
self.dispatch_count += 1
self.since_reset += 1
self.drain.bind()
self.drain.image('histogram_img', self.histogram)
self.drain.image('accum_img', self.accum)
# Drain and auto-expose across ALL bands: the exposure has to be common or
# each band would be normalised to its own peak and they would not add up.
tall = self.height * max(1, self.depth_slices)
self.drain.uniform_int('out_size', (self.width, tall))
gpu.compute.dispatch(self.drain, (self.width + 7) // 8, (tall + 7) // 8, 1)
self._tonemap_blit()
self.frames += 1
# Keep animating: without this the viewport only refines when nudged.
for area in context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
def _tonemap_blit(self):
"""
Auto-expose the accumulator and blit it to the viewport.
Shared by BOTH paths, deliberately. The histogram fills `accum` by
draining a uint histogram; the splat pass fills it by additive rasterising
gaussians. Everything after that — the peak scan, the tonemap curve
(Brightness, the shading modes, opacity gain, relief) and the depth
composite — is identical, and identical because it is the same code rather
than because two copies agree today.
"""
# Auto-exposure without a readback: the peak lives in a 1x1 uint image.
self.maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
self.maxpass.bind()
self.maxpass.image('accum_img', self.accum)
self.maxpass.image('max_img', self.maxbuf)
tall = self.height * max(1, self.depth_slices)
self.maxpass.uniform_int('out_size', (self.width, tall))
gpu.compute.dispatch(self.maxpass, (self.width + 7) // 8, (tall + 7) // 8, 1)
self.tonemap.bind()
self.tonemap.uniform_sampler('accum_tex', self.accum)
self.tonemap.uniform_sampler('max_tex', self.maxbuf)
self.tonemap.uniform_sampler('depth_tex', self.depthbuf)
self.tonemap.uniform_sampler('range_tex', self.rangebuf)
self.tonemap.uniform_int('out_size', (self.width, self.height))
self.tonemap.uniform_float('brightness', self.brightness)
self.tonemap.uniform_int('shading_mode', (self.shading_mode,))
self.tonemap.uniform_float('opacity_gain', self.opacity_gain)
self.tonemap.uniform_float('relief', self.relief)
# Emission ADDS light; the shaded modes are surfaces and composite over.
gpu.state.blend_set('ADDITIVE_PREMULT' if self.shading_mode == 0 else 'ALPHA')
# TEST against the scene's depth, but do NOT write to it. Writing would let
# the fractal occlude overlays drawn after us (gizmos, grid, text) and would
# leave the buffer holding a nebula's depth, which is meaningless to anything
# downstream. LESS_EQUAL rather than LESS so a sample coplanar with a surface
# still shows.
gpu.state.depth_mask_set(False)
gpu.state.depth_test_set('LESS_EQUAL')
# Back to front. Each band carries its own depth, so the hardware discards
# the ones a surface is in front of and keeps the rest — partial occlusion
# out of a binary test, with no access to Blender's depth texture.
self.tonemap.uniform_int('depth_slices', (self.depth_slices,))
for band in reversed(range(max(1, self.depth_slices))):
self.tonemap.uniform_int('slice', (band,))
self.batch.draw(self.tonemap)
gpu.state.depth_test_set('NONE')
gpu.state.blend_set('NONE') # leave the viewport's state as we found it
@property
def iterations(self):
return self.since_reset * self.iters_per_frame * ifs_render.INVOCATIONS
def start(document_path, iters_per_frame=400):
global _session
stop()
session = Session(document_path, iters_per_frame)
for tree in bpy.data.node_groups:
if tree.bl_idname == 'IFSNodeTree':
session.bind_tree(tree.name)
break
session.handle = bpy.types.SpaceView3D.draw_handler_add(
_draw_callback, (session,), 'WINDOW', 'POST_PIXEL')
_session = session
return session
def _draw_callback(session):
try:
session.draw(bpy.context)
except Exception:
# A raising draw handler is re-invoked every redraw and floods the console,
# so it removes itself and reports once.
import traceback
traceback.print_exc()
stop()
def stop():
global _session
if _session is not None and _session.handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(_session.handle, 'WINDOW')
_session.handle = None
_session = None
@bpy.app.handlers.persistent
def _on_depsgraph_update(scene, depsgraph):
"""Flags the session when its bound node tree is edited."""
if _session is None:
return
# Resolve FIRST: a tree created after the session started is exactly the case the
# old early-return made permanently invisible.
_session.resolve_tree()
if _session.tree_name is None:
return
for update in depsgraph.updates:
if getattr(update.id, "name", None) == _session.tree_name:
_session.mark_graph_dirty()
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
return
def install_handlers():
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
def remove_handlers():
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
def is_running():
return _session is not None
def session():
"""The live session. Callers must check `is_running()` first."""
return _session
def status():
if _session is None:
return "stopped"
return (f"{_session.frames} frames, {_session.iterations / 1e9:.2f}G iterations, "
f"{_session.width}x{_session.height}")