OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_layout.py

791 lines · python ref:
"""
Reshapes the engine's compute kernel onto Blender's GPU resource model.

`ifs_codegen.py` produces the kernel the .NET engine compiles. That kernel cannot
run under Blender's Python GPU module as-is, for four reasons, and this module
fixes each:

1. **No SSBOs.** `gpu.types.GPUStorageBuf` does not exist and
   `GPUShaderCreateInfo` has no `storage_buf` (tracker #145179 open, PR #161447
   stale). The kernel's two SSBOs become images.
2. **No float image atomics.** `imageAtomicAdd` accepts uint/int only, so the
   histogram accumulates in FIXED POINT and is periodically drained to float.
3. **Loose uniforms are push constants**, and the six here exceed the 128-byte
   portable limit Blender warns about. They move into a UBO.
4. **`alias_tables` overflows a UBO.** It is DECLARED `vec4[MAX_ITERATORS]` but
   INDEXED `[iterator_index * itnum + i]`, i.e. up to itnum² — 160 KB at 100
   iterators against a 64 KB cap. (That is a latent out-of-bounds read in the
   engine, which NVIDIA tolerates.) It becomes a sampler, which draws on a
   separate 32-slot budget rather than the 8 image slots.

Blender writes the `layout(binding = N)` qualifiers itself from the
`GPUShaderCreateInfo`, so the emitted GLSL carries no resource declarations and no
`#version`/`local_size` line. `LAYOUT` describes what the host must declare.

## The histogram encoding

One `R32UI` image, 4·W wide: pixel (x, y) channel c lives at texel (4x + c, y).
Four contiguous texels per pixel mirror the engine's `vec4 histogram[]` and put one
cache line under each pixel's four atomics. Four SEPARATE images measured 4x slower
— the coalescing is the single biggest factor in this kernel's throughput.

Values are scaled by `HIST_SCALE` and truncated. uint32 saturates at 4.29e9, so a
bright pixel needs draining before `hits · HIST_SCALE` reaches it; the host runs a
drain pass that folds the uint image into an `RGBA32F` accumulator and zeroes it.
Raising HIST_SCALE buys precision per hit and costs drain headroom.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

# Fixed-point scale for histogram accumulation. Per-hit contributions are opacity-
# weighted and <= ~1, so this is roughly "sub-1/1024 of a hit is discarded" against
# "drain before ~4.2M hits land on one pixel".
DEFAULT_HIST_SCALE = 1024.0


@dataclass(frozen=True)
class Resource:
    """One thing the host must declare on the GPUShaderCreateInfo."""

    kind: str  # "image" | "sampler" | "uniform_buf"
    slot: int
    name: str
    gpu_type: str  # e.g. "UINT_2D", "FLOAT_2D", or a UBO struct name
    note: str = ""
    instance: str = ""  # the identifier the GLSL body uses, when it differs


# Images are capped at 8 and the budget is STATIC — Blender emits layout(binding=N)
# for a declared image whether or not the body references it, so a spare slot cannot
# be reclaimed by leaving one unused. Three is comfortably inside it.
LAYOUT: tuple[Resource, ...] = (
    Resource("image", 0, "histogram_img", "UINT_2D",
             "R32UI, 4*W x H, fixed point. imageAtomicAdd target."),
    Resource("image", 1, "state_img", "FLOAT_2D",
             "RGBA32F, 2*STATE_W x ceil(n/STATE_W). Two texels per invocation."),
    Resource("sampler", 0, "alias_tables_tex", "FLOAT_2D",
             "RGBA32F itnum x itnum. Separate 32-slot budget, so no image cost."),
    Resource("uniform_buf", 0, "frame_ubo", "FrameParams",
             "The six former loose uniforms; they exceed the 128-byte push limit.",
             instance="frame"),
    Resource("uniform_buf", 1, "settings_ubo", "SettingsParams", "", instance="settings"),
    Resource("uniform_buf", 2, "iterators_ubo", "IteratorsParams", "", instance="iterators_buf"),
    Resource("uniform_buf", 3, "palette_ubo", "PaletteParams", "", instance="palette_buf"),
    Resource("uniform_buf", 4, "real_params_ubo", "RealParams", "", instance="real_params_buf"),
    Resource("uniform_buf", 5, "vec3_params_ubo", "Vec3Params", "", instance="vec3_params_buf"),
)

# --------------------------------------------------------------------- rewrites

_SSBO_BLOCK = re.compile(
    r"layout\(std140,\s*binding\s*=\s*\d+\)\s*(?:coherent\s+)?buffer\s+\w+\s*\{[^}]*\}\s*;",
    re.S,
)
_ALIAS_UBO_BLOCK = re.compile(
    r"layout\(std140,\s*binding\s*=\s*\d+\)\s*uniform\s+alias_tables_ubo\s*\{[^}]*\}\s*;",
    re.S,
)
_LOOSE_UNIFORM = re.compile(r"^uniform\s+\w+\s+\w+\s*;\s*$", re.M)
_VERSION = re.compile(r"^#version .*$", re.M)
_LOCAL_SIZE = re.compile(r"^layout\(local_size_x\s*=\s*\d+\)\s*in\s*;\s*$", re.M)
# Only the top-of-file extension guard. There is a SECOND
# `#ifdef GL_NV_shader_atomic_float` inside accumulate_hit selecting the atomic vs
# racy arm; that one must not be matched here, because the whole function is
# replaced below. Requiring the #extension line distinguishes them.
_ATOMIC_EXT = re.compile(
    r"#ifdef GL_NV_shader_atomic_float\s*\n\s*#extension GL_NV_shader_atomic_float[^\n]*\n\s*#endif\s*",
    re.S)
_PRECISION_EXT = re.compile(r"^#extension GL_ARB_shader_precision.*$", re.M)

# The whole accumulate_hit body, both arms of its #ifdef, replaced wholesale.
_ACCUMULATE = re.compile(
    r"void accumulate_hit\(ivec2 proj, vec4 color\)\s*\{.*?\n\}", re.S)

_ACCUMULATE_NEW = """void accumulate_hit(ivec2 proj, vec4 color)
{
\t// Fixed point, because imageAtomicAdd has no float overload. Four contiguous
\t// texels per pixel: one cache line per pixel, mirroring vec4 histogram[].
\tivec2 base = ivec2(proj.x * 4, proj.y);
\timageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
\timageAtomicAdd(histogram_img, base + ivec2(1, 0), uint(max(color.g, 0.0) * HIST_SCALE));
\timageAtomicAdd(histogram_img, base + ivec2(2, 0), uint(max(color.b, 0.0) * HIST_SCALE));
\timageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE));
}"""

# ---------------------------------------------------------------------- depth mode
#
# The live overlay draws a fullscreen quad, so to let scene geometry occlude it the
# quad must hand the hardware ONE depth per pixel. A flame has no such thing: it is
# an accumulation along the whole ray. The NEAREST contributing sample is the honest
# single value - it errs toward showing the fractal, and it is exactly right whenever
# the fractal is wholly in front of or wholly behind an object. Only interpenetration
# is approximated, and then per pixel.
#
# Depth is written INSIDE accumulate_hit rather than at the call sites because there
# are two of them: the direct plot, and the density-estimation filter that splats
# into NEIGHBOURING pixels. Writing at the call site would leave every filtered pixel
# at the far plane, so blurred regions would silently refuse to be occluded.

# 1.0 - the far plane. A pixel nothing landed on contributes nothing, and comparing
# LESS_EQUAL against a cleared depth buffer of 1.0 leaves it a no-op either way.
DEPTH_CLEAR = 0x3F800000

_DEPTH_HELPERS = """
// Nearest-sample depth, in Blender's window space. The bit pattern of a float in
// [0,1] is monotonic, so imageAtomicMin over floatBitsToUint is an EXACT minimum -
// no fixed-point scale and no quantisation, unlike the histogram.
uint g_depth = 0x3F800000u;
float g_depth_f = 1.0;   // the same value, kept as a float for slice selection

// Blender's own view-projection, NOT the engine's. `view_proj_mat` is mirrored in x
// and carries the FOV quirk, and its NDC z is not what Blender's depth buffer holds.
// Taking the matrix from region_3d means our z matches the buffer BY CONSTRUCTION
// rather than by re-deriving the near/far mapping here and hoping it agrees.
void set_depth(vec3 world)
{
\tvec4 h = blender_view_proj * vec4(world, 1.0);
\tif (h.w <= 0.0)
\t{
\t\tg_depth = 0x3F800000u;   // behind the camera: never occludes anything
\t\treturn;
\t}
\tg_depth = floatBitsToUint(clamp((h.z / h.w) * 0.5 + 0.5, 0.0, 1.0));
}
"""

_ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color)
{
	// SLICED BY DEPTH. The histogram is `depth_slices` screens tall and a sample
	// lands in the band its own depth falls into.
	//
	// One depth and one colour per pixel cannot answer the only question that
	// matters where a cube sits inside a nebula: not "is the fractal in front?" but
	// "how much of the emission along this ray is in front?". With a single buffer
	// the nearest-sample depth is a MIN over stochastic samples — a random variable —
	// so LESS_EQUAL flips per pixel and the full brightness lands wherever it passes.
	// Measured as 0.4995 neighbour-to-neighbour roughness against 0.0000 unoccluded:
	// essentially maximal salt-and-pepper, and exactly the white speckle seen in the
	// viewport.
	//
	// Slices turn that into an N-step answer. Each band is blitted separately with
	// its own gl_FragDepth, so the hardware rejects whole bands per pixel and the
	// dithering has nothing to come from. No access to Blender's depth texture is
	// needed — which is just as well, because Python cannot obtain one (issue #7).
	// Banded over the range the fractal ACTUALLY occupies, not over [0,1].
	//
	// Window depth is violently nonlinear: with Blender's default clipping a scene
	// spanning distance 5..20 spans window depth 0.980..0.995. Uniform bands over
	// [0,1] therefore put EVERY sample in the last band, whose far edge is 1.0 —
	// behind everything — so the fractal was fully occluded by any object rather
	// than partially. Measured as arithmetic before it was measured on screen.
	//
	// The range comes from the PREVIOUS frame's depth buffer, reduced on the GPU
	// (no readback, which would stall the pipeline the auto-exposure deliberately
	// avoids stalling). It is self-correcting: the viewport is progressive and the
	// camera moves between frames far less than the fractal's own depth extent.
	// Texel 0 holds the minimum COMPLEMENTED so one clear can seed both reductions;
	// see the note in ifs_viewport's _DEPTH_RANGE.
	float dmin = uintBitsToFloat(~imageLoad(depth_range_img, ivec2(0, 0)).x);
	float dmax = uintBitsToFloat(imageLoad(depth_range_img, ivec2(1, 0)).x);
	float band_t = (dmax > dmin)
		? clamp((g_depth_f - dmin) / (dmax - dmin), 0.0, 1.0)
		: 0.0;   // first frame, or a fractal with no depth extent: one band
	int slice = clamp(int(band_t * float(depth_slices)), 0, depth_slices - 1);
	ivec2 base = ivec2(proj.x * 4, proj.y + slice * height);
	imageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
	imageAtomicAdd(histogram_img, base + ivec2(1, 0), uint(max(color.g, 0.0) * HIST_SCALE));
	imageAtomicAdd(histogram_img, base + ivec2(2, 0), uint(max(color.b, 0.0) * HIST_SCALE));
	imageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE));
	// Still written: the nearest depth is what a single-slice consumer wants, and
	// the still render has no slices.
	imageAtomicMin(depth_img, proj, g_depth);
}"""

# Set once per sample, at the top of the plot block, so BOTH accumulate_hit arms
# carry the same depth. `p.pos.xyz` is the sample's world position - the same value
# the volume path voxelises.
_DEFOCUS_DECL = re.compile(r"(^[ \t]*float defocus;)", re.M)

DEPTH_EXTRA_LAYOUT: tuple[Resource, ...] = (
    Resource("image", 3, "depth_range_img", "UINT_2D",
             "R32UI, 2x1. Texel 0 = min, texel 1 = max depth of the PREVIOUS "
             "frame, used to band over the range the fractal really occupies."),
    Resource("image", 2, "depth_img", "UINT_2D",
             "R32UI, W x H. Nearest-sample window depth as float bits; "
             "imageAtomicMin target. Cleared to DEPTH_CLEAR."),
)

# p_state is 8 floats; the two ints ride through as bit patterns so nothing is lost
# to float rounding on a large iterator index or depth.
_STATE_HELPERS = """
#define HIST_SCALE {hist_scale}
#define STATE_W {state_w}

ivec2 state_texel(uint gid)
{{
\treturn ivec2(2 * int(gid % uint(STATE_W)), int(gid / uint(STATE_W)));
}}

p_state load_state(uint gid)
{{
\tivec2 st = state_texel(gid);
\tvec4 a = imageLoad(state_img, st);
\tvec4 b = imageLoad(state_img, st + ivec2(1, 0));
\tp_state p;
\tp.pos = a;
\tp.color_index = b.x;
\tp.dummy0 = b.y;
\tp.iterator_index = floatBitsToInt(b.z);
\tp.iteration_depth = floatBitsToInt(b.w);
\treturn p;
}}

void store_state(uint gid, p_state p)
{{
\tivec2 st = state_texel(gid);
\timageStore(state_img, st, p.pos);
\timageStore(state_img, st + ivec2(1, 0), vec4(
\t\tp.color_index, p.dummy0,
\t\tintBitsToFloat(p.iterator_index), intBitsToFloat(p.iteration_depth)));
}}
"""


# Remaining UBO blocks. Blender declares these through `uniform_buf(slot, Type, name)`
# and writes the layout qualifier itself, so the body must not declare them.
#
# `settings_ubo` already has an instance alias (`settings`), and the body uses
# `settings.itnum` — declaring the instance as `settings` keeps every use working.
# The others declare a bare array, so the array is wrapped in a struct and a #define
# restores the original spelling. That is safe ONLY because codegen has already
# rewritten every plugin's `@seed` / `@height` into real_params lookups, leaving no
# plugin-local identifier for a #define to capture.
_UBO_BLOCK = re.compile(
    r"layout\(std140,\s*binding\s*=\s*\d+\)\s*uniform\s+(\w+)\s*\{(.*?)\}\s*(\w*)\s*;", re.S)

_UBO_STRUCTS = {
    "settings_ubo": ("SettingsParams", "settings"),
    "iterators_ubo": ("IteratorsParams", "iterators_buf"),
    "palette_ubo": ("PaletteParams", "palette_buf"),
    "real_params_ubo": ("RealParams", "real_params_buf"),
    "vec3_params_ubo": ("Vec3Params", "vec3_params_buf"),
}

_ARRAY_DECL = re.compile(r"(\w+)\s+(\w+)\s*\[([^\]]+)\]\s*;")

# The former loose uniforms. std140 orders these as declared; the host must pack
# them identically.
_FRAME_STRUCT = """struct FrameParams
{
\tuint seed;
\tint width;
\tint height;
\tint dispatch_cnt;
\tint reset_points_state;
\tint invocation_iters;
};"""

_FRAME_DEFINES = "\n".join(
    f"#define {n} frame.{n}"
    for n in ("seed", "width", "height", "dispatch_cnt",
              "reset_points_state", "invocation_iters"))


# camera_params / Iterator / p_state are declared in the body, but SettingsParams
# and the state helpers reference them and Blender emits typedef_source BEFORE
# compute_source. They have to move up with the UBO structs.
_SHARED_STRUCTS = ("camera_params", "Iterator", "p_state")

# The array-size defines must precede the structs that use them, and typedef_source
# is emitted before compute_source, so they are COPIED (not moved) into the typedefs
# — the body still needs them too.
_SIZE_DEFINE = re.compile(r"^#define\s+(MAX_\w+)\s+.*$", re.M)


def extract_structs(source: str) -> tuple[str, str]:
    """Lifts the shared struct definitions out of the body, with their size defines."""
    lifted: list[str] = [m.group(0) for m in _SIZE_DEFINE.finditer(source)]
    if not lifted:
        raise ValueError("no MAX_* size defines found")
    for name in _SHARED_STRUCTS:
        pattern = re.compile(r"^struct\s+" + name + r"\s*\n\{.*?\n\};", re.S | re.M)
        match = pattern.search(source)
        if match is None:
            raise ValueError(f"struct {name} not found")
        lifted.append(match.group(0))
        source = source[:match.start()] + source[match.end():]
    return source, "\n\n".join(lifted)


def extract_ubos(source: str) -> tuple[str, str, str]:
    """
    Strips the UBO blocks, returning (source, typedef_glsl, defines_glsl).

    The struct bodies are COPIED from the kernel rather than transcribed here, so a
    field added upstream flows through instead of silently diverging.
    """
    typedefs: list[str] = [_FRAME_STRUCT]
    defines: list[str] = [_FRAME_DEFINES]
    removed: list[str] = []

    def take(match: re.Match) -> str:
        block, body, alias = match.group(1), match.group(2), match.group(3)
        if block not in _UBO_STRUCTS:
            return match.group(0)  # left alone; reported as unhandled below
        struct_name, instance = _UBO_STRUCTS[block]
        removed.append(block)
        if alias:
            typedefs.append(f"struct {struct_name}\n{{{body}}};")
        else:
            decl = _ARRAY_DECL.search(body)
            if decl is None:
                raise ValueError(f"{block}: expected a single array declaration")
            elem_type, array_name, size = decl.groups()
            typedefs.append(
                f"struct {struct_name}\n{{\n\t{elem_type} data[{size}];\n}};")
            defines.append(f"#define {array_name} {instance}.data")
        return ""

    out = _UBO_BLOCK.sub(take, source)
    missing = set(_UBO_STRUCTS) - set(removed)
    if missing:
        raise ValueError(f"UBO blocks not found in kernel: {sorted(missing)}")
    return out, "\n\n".join(typedefs), "\n".join(defines)


# ------------------------------------------------------------------- volume mode
#
# Instead of projecting to a 2D histogram, accumulate into a 3D voxel grid. That
# turns the chaos game's output into a density field, which is what OpenVDB stores
# and what Cycles renders natively - so the fractal becomes real scene data rather
# than an overlay.
#
# The projection block is replaced wholesale rather than bypassed: project() can
# `continue` on out-of-frame, which would silently drop every voxel outside the
# current view and bake a view-dependent volume.

_PLOT_BLOCK = re.compile(
    r"[ \t]*float defocus;.*?accumulate_hit\(proj, color\);\s*\}", re.S)

_PLOT_VOLUME = """		vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity);
		if (color.w == 0.0)
			continue;
		color.xyz *= color.w;
		accumulate_voxel(p.pos.xyz, color);"""

_VOXEL_HELPER = """
// Density at FULL resolution, colour at HALF.
//
// The grid used to be four interleaved channels per voxel (RGB + density) at one
// resolution, which cost 16 bytes per voxel: 268 MB at N=256 and 2.1 GB at N=512.
// Splitting them costs 5.5 bytes per voxel instead — a 2.9x saving — because
// colour is the channel that can afford it. Density carries the structure and is
// what the fractal's detail lives in; hue varies smoothly across a flame, so
// halving its resolution is invisible where halving density's would not be.
//
// OpenVDB grids each carry their own transform, so the half-res colour grid is
// written with twice the voxel size and Blender samples it correctly with no
// upsampling on our side. Verified before this was built, not assumed.
//
// Colour arrives premultiplied by opacity (see _PLOT_VOLUME), so recovering it
// means dividing by the density in the SAME colour voxel — which is the sum of
// the 2x2x2 density block, computed host-side rather than accumulated here. That
// is what keeps this to three channels instead of four.
void accumulate_voxel(vec3 pos, vec4 color)
{
\tvec3 span = max(volume_max - volume_min, vec3(1e-9));
\tvec3 t = (pos - volume_min) / span;
\tif (any(lessThan(t, vec3(0.0))) || any(greaterThanEqual(t, vec3(1.0))))
\t\treturn;   // outside the bake bounds; the chaos game is unbounded
\t// Stored TRANSPOSED, deliberately: voxel (x,y,z) goes to texel (z,y,x).
\t//
\t// GPUTexture.read() hands back a 3D texture as [z][y][x], while openvdb's
\t// copyFromArray reads index (i,j,k) as world (x,y,z) — so the host used to fix
\t// this up with `ascontiguousarray(density.transpose(2,1,0))`. That is a full
\t// strided copy of the whole grid with cache-hostile access: measured 17.6s at
\t// N=512, about 70% of the entire write, against 0.19s for copyFromArray itself.
\t// Permuting the index HERE is free — it is three registers — and the readback
\t// then arrives in exactly the order openvdb wants.
\t//
\t// (Passing the transposed VIEW to copyFromArray instead does NOT work: it reads
\t// the underlying buffer in memory order and silently ignores the strides, which
\t// writes the fractal transposed. test_volume_axes and test_bake_orientation both
\t// catch it; activeVoxelCount does not, being invariant under permutation.)
\tivec3 v = ivec3(t * vec3(volume_size));
\timageAtomicAdd(volume_img, ivec3(v.z, v.y, v.x),
\t               uint(max(color.w, 0.0) * HIST_SCALE));

\t// Three contiguous texels per colour voxel, the same coalescing argument the
\t// old four-channel layout made.
\t// Same permutation as density, and the channel triple stays on the FIRST axis
\t// so a colour voxel is still three contiguous texels.
\tivec3 c = ivec3(t * vec3(volume_size >> 1));
\tivec3 cbase = ivec3(c.z * 3, c.y, c.x);
\timageAtomicAdd(colour_img, cbase + ivec3(0, 0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
\timageAtomicAdd(colour_img, cbase + ivec3(1, 0, 0), uint(max(color.g, 0.0) * HIST_SCALE));
\timageAtomicAdd(colour_img, cbase + ivec3(2, 0, 0), uint(max(color.b, 0.0) * HIST_SCALE));
}
"""

VOLUME_LAYOUT: tuple[Resource, ...] = (
    Resource("image", 0, "volume_img", "UINT_3D",
             "R32UI, N x N x N density, fixed point. imageAtomicAdd target."),
    Resource("image", 2, "colour_img", "UINT_3D",
             "R32UI, 3(N/2) x N/2 x N/2 premultiplied colour, HALF resolution."),
    Resource("image", 1, "state_img", "FLOAT_2D", "as the 2D path"),
    Resource("sampler", 0, "alias_tables_tex", "FLOAT_2D", ""),
    Resource("uniform_buf", 0, "frame_ubo", "FrameParams", "", instance="frame"),
    Resource("uniform_buf", 1, "settings_ubo", "SettingsParams", "", instance="settings"),
    Resource("uniform_buf", 2, "iterators_ubo", "IteratorsParams", "", instance="iterators_buf"),
    Resource("uniform_buf", 3, "palette_ubo", "PaletteParams", "", instance="palette_buf"),
    Resource("uniform_buf", 4, "real_params_ubo", "RealParams", "", instance="real_params_buf"),
    Resource("uniform_buf", 5, "vec3_params_ubo", "Vec3Params", "", instance="vec3_params_buf"),
)


# ------------------------------------------------------------------- points mode
#
# Emit the chaos game's samples as POINTS instead of binning them, and hand them to
# Cycles as a PointCloud. Where volume mode trades resolution against memory - the
# grid is 4N x N x N, so 512 is 2.1 GB and does not fit - points sit at float
# precision and have no resolution ceiling at all. The cost moves from "how finely
# can the grid resolve it" to "how many samples can be stored", which is a straight
# linear trade instead of a cubic one.
#
# The obstacle is that there is no buffer to append to: Blender's GPU module has no
# SSBOs (module docstring, reason 1). An image is the substitute - one texel per
# point in an RGBA32F image - and the append is an atomic counter in a 1x1 R32UI,
# which is the viewport's auto-exposure max pass turned from a reduction into an
# allocator.
#
# The plot block is replaced wholesale for the same reason volume mode replaces it:
# project() can `continue` on out-of-frame, so anything that merely ignored the
# projection would still drop every sample outside the current view and emit a
# view-dependent cloud.

# The kernel draws the chaos game's map choice from `gl_WorkGroupID.x`, so all 64
# invocations in a workgroup follow the SAME sequence of maps. That is deliberate and
# it is a good trade for a histogram: every thread in the workgroup takes the same
# branch through the transform switch, which is a large SIMD win, and the duplicate
# trajectories it produces merely add density to the same bins. Nothing is lost when
# the output is a histogram accumulated over hundreds of dispatches.
#
# For a point cloud it is quietly ruinous, because duplicates become actual duplicate
# points and one dispatch is the whole sample. MEASURED on the orientation document at
# 200k points: 27% of emitted points were exact duplicates, the four maps came out
# [50216, 46516, 50951, 53021] against 50176 expected - a 7.3% deficit on one of them,
# which showed up as the attractor's centroid sitting 0.014 low on the axis that map
# is responsible for. The draw pool is only `workgroups x iterations` values wide,
# so a single dispatch never averages it out.
#
# Points mode therefore draws per INVOCATION. It costs branch divergence inside each
# workgroup - the throughput trade the engine deliberately made in the other
# direction - and buys 16384 independent chains instead of 256.
_WORKGROUP_DRAW = re.compile(r"f_hash4\(seed, gl_WorkGroupID\.x,")

_PLOT_EQUIRECT = """		vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity);
		if (color.w == 0.0)
			continue;
		color.xyz *= color.w;
		accumulate_equirect(p.pos.xyz, color);"""

_EQUIRECT_HELPER = """
// The fractal as an ENVIRONMENT: each sample's DIRECTION from a centre, mapped to
// equirectangular lat/long, plotted into the ordinary 2D histogram.
//
// This is the smallest of the plot modes because it changes only the projection.
// The histogram, the drain, the auto-exposure and the tonemap are all reused
// untouched, so an environment bake goes through exactly the same curve as the
// viewport and the still render — which is the whole point: it has to look like
// the fractal, not like a second renderer.
//
// It is also the mode with NO depth channel, and that is not a limitation. An
// environment texture is infinitely far away, so there is nothing to be occluded
// by and no compositing question at all — the problem the overlay has with a cube
// inside a nebula simply does not arise here.
//
// Blender's world is Z-up and its equirectangular convention runs u around the
// azimuth and v from the top pole down.
void accumulate_equirect(vec3 pos, vec4 color)
{
\tvec3 d = pos - equirect_centre;
\tfloat len = length(d);
\tif (len < 1e-9)
\t\treturn;              // a sample AT the centre has no direction
\td /= len;

\tconst float TWO_PI = 6.28318530717958647692;
\tfloat u = atan(d.y, d.x) / TWO_PI + 0.5;
\tfloat v = acos(clamp(d.z, -1.0, 1.0)) / 3.14159265358979323846;

\t// Bare `width`/`height`: _FRAME_DEFINES aliases them to frame.width/frame.height,
\t// so spelling the struct out here would expand to frame.frame.width.
\tivec2 proj = ivec2(u * float(width), v * float(height));
\tproj = clamp(proj, ivec2(0), ivec2(width - 1, height - 1));
\taccumulate_hit(proj, color);
}
"""

_PLOT_POINTS = """		vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity);
		if (color.w == 0.0)
			continue;
		emit_point(p.pos.xyz, color);"""

_EMIT_HELPER = """
// An append-only list built out of an image, because there are no SSBOs to append
// to. `imageAtomicAdd` returns the PREVIOUS value, so every invocation claims a
// unique slot with no lock and no ordering assumption.
//
// The counter deliberately keeps counting PAST the end of the image and the store
// is guarded instead. That costs one comparison and buys the host the difference
// between "this cloud is the whole run" and "this cloud is the first million of
// twelve" - a truncated point cloud is still a plausible fractal, so silence there
// would be the same class of quiet wrongness as a transposed bake.
void emit_point(vec3 pos, vec4 color)
{
	uint slot = imageAtomicAdd(counter_img, ivec2(0, 0), 1u);
	if (slot >= uint(point_capacity))
		return;
	ivec2 texel = ivec2(int(slot % uint(points_width)), int(slot / uint(points_width)));
	imageStore(points_img, texel, vec4(pos, 1.0));
	imageStore(colors_img, texel, color);
}
"""

POINTS_LAYOUT: tuple[Resource, ...] = (
    Resource("image", 0, "points_img", "FLOAT_2D",
             "RGBA32F, points_width x ceil(capacity/points_width). xyz + 1 per point."),
    Resource("image", 1, "colors_img", "FLOAT_2D",
             "RGBA32F, same shape. Palette rgb + the iterator's opacity."),
    Resource("image", 2, "counter_img", "UINT_2D",
             "R32UI 1x1. imageAtomicAdd allocator; counts past capacity on purpose."),
    Resource("image", 3, "state_img", "FLOAT_2D", "as the 2D path"),
    Resource("sampler", 0, "alias_tables_tex", "FLOAT_2D", ""),
    Resource("uniform_buf", 0, "frame_ubo", "FrameParams", "", instance="frame"),
    Resource("uniform_buf", 1, "settings_ubo", "SettingsParams", "", instance="settings"),
    Resource("uniform_buf", 2, "iterators_ubo", "IteratorsParams", "", instance="iterators_buf"),
    Resource("uniform_buf", 3, "palette_ubo", "PaletteParams", "", instance="palette_buf"),
    Resource("uniform_buf", 4, "real_params_ubo", "RealParams", "", instance="real_params_buf"),
    Resource("uniform_buf", 5, "vec3_params_ubo", "Vec3Params", "", instance="vec3_params_buf"),
)


@dataclass
class RewriteReport:
    """What the rewrite did, so a silent no-op is impossible to miss."""

    applied: list[str]
    missing: list[str]

    @property
    def ok(self) -> bool:
        return not self.missing


def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
            state_width: int = 256,
            volume: bool = False,
            depth: bool = False,
            points: bool = False,
            equirect: bool = False) -> tuple[str, str, RewriteReport]:
    """
    Rewrites an engine kernel onto the Blender resource model.

    Every substitution is checked: if the kernel changes upstream such that a
    pattern no longer matches, that lands in `report.missing` rather than silently
    producing a kernel that compiles and behaves differently.

    `depth` adds the nearest-sample depth channel the viewport overlay needs to be
    occluded by scene geometry. It is meaningless with `volume`, which replaces the
    2D projection wholesale and so has no screen pixel to be in front of.

    `volume` and `points` are the two ways of turning the chaos game into real scene
    data - a density grid or a list of samples. They are alternatives, not layers:
    each replaces the plot block with its own write, and each binds image slot 0 to
    something the other would read as a different type.
    """
    if volume and depth:
        raise ValueError("depth is a 2D-projection concept; it has no meaning in "
                         "volume mode, which never projects to a screen pixel")
    if equirect and depth:
        raise ValueError("depth has no meaning in equirect mode: an environment is "
                         "infinitely far away and is never occluded")
    chosen = [name for name, on in
              (("volume", volume), ("points", points), ("equirect", equirect),
               ("depth", depth)) if on]
    if len(chosen) > 1:
        raise ValueError(
            f"{' and '.join(chosen)} cannot be combined: each replaces the plot "
            "block with a different write and binds the image slots under "
            "different types")
    applied: list[str] = []
    missing: list[str] = []

    def sub(pattern: re.Pattern, replacement: str, label: str, text: str,
            expected: int = 1) -> str:
        new, count = pattern.subn(replacement, text)
        if count != expected:
            missing.append(f"{label}: matched {count}, expected {expected}")
        else:
            applied.append(label)
        return new

    out = source

    # Blender owns the preamble.
    out = sub(_VERSION, "", "drop #version", out)
    out = sub(_LOCAL_SIZE, "", "drop local_size (GPUShaderCreateInfo sets it)", out)
    out = sub(_ATOMIC_EXT, "", "drop GL_NV_shader_atomic_float guard", out)
    out = sub(_PRECISION_EXT, "", "drop GL_ARB_shader_precision", out)

    # Resource declarations: the host declares these.
    out = sub(_SSBO_BLOCK, "", "drop SSBO declarations", out, expected=2)
    out = sub(_ALIAS_UBO_BLOCK, "", "drop alias_tables UBO", out)
    out = sub(_LOOSE_UNIFORM, "", "drop loose uniforms (moved to frame_ubo)", out, expected=6)

    # Accesses.
    if volume:
        out = sub(_PLOT_BLOCK, lambda _m: _PLOT_VOLUME,
                  "replace the projection block with a voxel write", out)
        out = sub(_ACCUMULATE, lambda _m: _VOXEL_HELPER,
                  "swap accumulate_hit for accumulate_voxel", out)
    elif points:
        out = sub(_PLOT_BLOCK, lambda _m: _PLOT_POINTS,
                  "replace the projection block with a point emit", out)
        out = sub(_ACCUMULATE, lambda _m: _EMIT_HELPER,
                  "swap accumulate_hit for emit_point", out)
        out = sub(_WORKGROUP_DRAW, "f_hash4(seed, gl_GlobalInvocationID.x,",
                  "draw the chaos game per invocation, not per workgroup",
                  out, expected=3)
    elif equirect:
        # Keeps accumulate_hit — equirect only changes WHERE a sample lands, not
        # how it accumulates — and adds the direction-to-lat/long helper beside it.
        out = sub(_PLOT_BLOCK, lambda _m: _PLOT_EQUIRECT,
                  "replace the projection block with an equirect plot", out)
        out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW + _EQUIRECT_HELPER,
                  "keep accumulate_hit and add accumulate_equirect", out)
    elif depth:
        out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_DEPTH,
                  "rewrite accumulate_hit with depth", out)
        out = sub(_DEFOCUS_DECL, lambda m: m.group(1) + "\n\t\tset_depth(p.pos.xyz);",
                  "inject set_depth at the top of the plot block", out)
    else:
        out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW, "rewrite accumulate_hit", out)
    out = sub(re.compile(r"\bp\s*=\s*state\[gid\]\s*;"), "p = load_state(gid);",
              "rewrite state load", out)
    out = sub(re.compile(r"\bstate\[gid\]\s*=\s*p\s*;"), "store_state(gid, p);",
              "rewrite state store", out)
    out = sub(
        re.compile(r"vec4 t = alias_tables\[iterator_index \* settings\.itnum \+ i\];"),
        "vec4 t = texelFetch(alias_tables_tex, ivec2(i, iterator_index), 0);",
        "rewrite alias_tables to texelFetch", out)

    # The helpers reference p_state, so they must follow its declaration.
    anchor = "//Shader Storage Buffer Objects"
    helpers = _STATE_HELPERS.format(hist_scale=hist_scale, state_w=state_width)
    if depth:
        helpers += _DEPTH_HELPERS
    if anchor in out:
        out = out.replace(anchor, helpers + "\n" + anchor, 1)
        applied.append("insert state helpers")
    else:
        missing.append("insert state helpers: anchor comment not found")

    # UBOs last: the alias_tables block is removed above, and this handles the rest.
    try:
        out, structs = extract_structs(out)
        out, typedefs, defines = extract_ubos(out)
        typedefs = structs + "\n\n" + typedefs
        applied.append("lift shared structs into typedefs")
        applied.append("strip UBO blocks into typedefs + defines")
    except ValueError as ex:
        missing.append(f"extract_ubos: {ex}")
        typedefs, defines = "", ""

    out = defines + "\n\n" + out
    return out, typedefs, RewriteReport(applied=applied, missing=missing)


def create_info_source(hist_scale: float = DEFAULT_HIST_SCALE) -> str:
    """
    The Python the host runs to declare the layout above.

    Emitted as source rather than executed so it can be read, diffed and pasted into
    an addon without importing `gpu` — this module stays importable outside Blender,
    which is what lets it be tested.

    The image qualifiers are load-bearing and were found by compiling: Blender's
    default is write-only, so `imageLoad` on the state image and `imageAtomicAdd` on
    the histogram both fail with "cannot be converted to less qualified parameter"
    unless READ and WRITE are both requested.
    """
    formats = {"histogram_img": "R32UI", "state_img": "RGBA32F"}
    lines = [
        "import gpu",
        "info = gpu.types.GPUShaderCreateInfo()",
        "info.typedef_source(TYPEDEFS)",
        "info.local_group_size(64)",
    ]
    for res in LAYOUT:
        if res.kind == "image":
            lines.append(
                f"info.image({res.slot}, {formats[res.name]!r}, {res.gpu_type!r}, "
                f"{res.name!r}, qualifiers={{'NO_RESTRICT', 'READ', 'WRITE'}})")
        elif res.kind == "sampler":
            lines.append(f"info.sampler({res.slot}, {res.gpu_type!r}, {res.name!r})")
        else:
            lines.append(
                f"info.uniform_buf({res.slot}, {res.gpu_type!r}, {res.instance!r})")
    lines.append("info.compute_source(BODY)")
    lines.append("shader = gpu.shader.create_from_info(info)")
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    import argparse
    import os

    from ifs_codegen import apply_portability_fixes, generate_kernel, load_library

    repo_default = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
    parser.add_argument("--repo", default=repo_default)
    parser.add_argument("--out", required=True, help="write the Blender-shaped kernel body here")
    parser.add_argument("--hist-scale", type=float, default=DEFAULT_HIST_SCALE)
    parser.add_argument("--state-width", type=int, default=256)
    args = parser.parse_args(argv)

    library = load_library(
        os.path.join(args.repo, "Transforms"),
        os.path.join(args.repo, "WpfDisplay", "Library", "Includes"))
    template_path = os.path.join(
        args.repo, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader")
    with open(template_path, "r", encoding="utf-8-sig") as handle:
        template = handle.read()

    kernel = generate_kernel(template, library)
    kernel, _ = apply_portability_fixes(kernel)
    body, typedefs, report = rewrite(kernel, args.hist_scale, args.state_width)

    for item in report.applied:
        print(f"ok   {item}")
    for item in report.missing:
        print(f"FAIL {item}")

    with open(args.out, "w", encoding="utf-8") as handle:
        handle.write(body)
    typedef_path = os.path.splitext(args.out)[0] + ".typedefs.glsl"
    with open(typedef_path, "w", encoding="utf-8") as handle:
        handle.write(typedefs)
    print(f"wrote {typedef_path}")
    print(f"wrote {args.out} ({len(body.splitlines())} lines, "
          f"{len(library.plugins)} plugins)")
    return 0 if report.ok else 1


if __name__ == "__main__":
    raise SystemExit(main())