"""
Host side of the Blender port: packs the kernel's uniform blocks and builds the
xaos alias tables.
The kernel reads six UBOs whose layouts are fixed by `IFSEngine/Rendering/GpuStructs`
and std140. Every struct there is built from 4-byte scalars grouped into vec4s, so
C#'s sequential layout and std140 happen to coincide — with ONE exception that is
easy to get wrong and silent when wrong:
a std140 `float[]` has a **16-byte stride**, not 4.
`real_params` is a `float[MAX_PARAMS]`, and the engine uploads it as
`allRealParams.Select(f => new Vector4(f))` (RendererGL.cs:500) — one float widened
to four. `pack_real_params` does the same. Packing them tightly would put parameter
N where the shader looks for parameter 4N and quietly render a different fractal.
Sizes are asserted rather than trusted: `test_host.py` checks each packed block
against the size the GLSL declaration implies.
"""
from __future__ import annotations
import math
import struct
from dataclasses import dataclass, field
MAX_ITERATORS = 100
MAX_PALETTE_COLORS = 150
MAX_PARAMS = 2 * MAX_ITERATORS
# std140: a scalar array's element stride is rounded up to a vec4.
STD140_ARRAY_STRIDE = 16
# ------------------------------------------------------------------ alias method
def generate_alias_table(p: list[float]) -> list[tuple[float, int]]:
"""
Vose alias table for a discrete distribution — a port of
`IFSEngine/Utility/AliasMethod.GenerateAliasTable`.
Returns (u, k) per entry: sample i uniformly, then take i if the fractional part
is below u, else the alias k. The engine's numerical-instability handling is
preserved exactly: when only one of the overfull/underfull groups remains, the
survivor is forced to exactly full rather than looping forever.
"""
n = len(p)
if n == 0:
return []
K = [0] * n
U = [(n * pi, _sign(n * pi - 1.0)) for pi in p]
for i in range(n):
if U[i][0] == 1:
K[i] = i
while any(group != 0 for _v, group in U):
i = _find(U, 1) # overfull
j = _find(U, -1) # underfull
if i == -1:
U[j] = (1.0, 0)
if j == -1:
U[i] = (1.0, 0)
if i == -1 or j == -1:
continue
K[j] = i
new_ui = U[i][0] + U[j][0] - 1.0
U[j] = (U[j][0], 0)
U[i] = (new_ui, _sign(new_ui - 1.0))
return [(U[i][0], K[i]) for i in range(n)]
def _sign(x: float) -> int:
return (x > 0) - (x < 0)
def _find(table: list[tuple[float, int]], group: int) -> int:
for index, (_v, g) in enumerate(table):
if g == group:
return index
return -1
# ---------------------------------------------------------------- document model
@dataclass
class Iterator:
"""One iterator. Field order is `IteratorStruct`'s and must not be reordered."""
color_speed: float = 0.5
color_index: float = 0.0
opacity: float = 1.0
reset_prob: float = 0.0
reset_alias: int = -1
tf_id: int = 0
real_params_index: int = 0
vec3_params_index: int = 0
shading_mode: int = 0
# Mix = 1.0 is the engine's default (Iterator.cs:23) and is LOAD-BEARING: the
# kernel computes `mix(p0, p_ret + p0*tf_add, tf_mix)`, so at 0.0 the transform
# result is discarded and every point stays where it was reset. The render is
# then a uniform scatter that still looks like "the GPU is doing something".
tf_mix: float = 1.0
tf_add: float = 0.0
base_weight: float = 1.0
real_params: list[float] = field(default_factory=list)
vec3_params: list[tuple[float, float, float]] = field(default_factory=list)
# xaos: destination index -> multiplier. Missing entries are 0.
weight_to: dict[int, float] = field(default_factory=dict)
@dataclass
class Camera:
view_proj: tuple[float, ...] = tuple(
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
position: tuple[float, float, float, float] = (0, 0, -5, 1)
forward: tuple[float, float, float, float] = (0, 0, 1, 0)
focus_point: tuple[float, float, float, float] = (0, 0, 0, 1)
aperture: float = 0.0
focus_distance: float = 5.0
depth_of_field: float = 0.25
projection_type: int = 0
@dataclass
class Settings:
fog_effect: float = 0.0
itnum: int = 0
# The engine uses 256 (RendererGL.cs:137) against a `vec4 palette[150]`
# declaration, over-running the UBO by 106 entries - the same latent
# out-of-bounds read as alias_tables, which NVIDIA tolerates. Staying inside
# the declared array costs 150 vs 256 gradient steps, which is imperceptible,
# and is well-defined.
gradient_resolution: int = MAX_PALETTE_COLORS
mark_area_in_focus: int = 0
warmup: int = 20
entropy: float = 0.01
max_filter_radius: int = 0
filter_method: int = 0
filter_param0: float = 0.0
filter_param1: float = 0.0
filter_param2: float = 0.0
# ----------------------------------------------------------------------- packing
def pack_camera(camera: Camera) -> bytes:
"""128 bytes: mat4 + 3 vec4 + 4 scalars."""
if len(camera.view_proj) != 16:
raise ValueError("view_proj must be 16 floats, column-major as GLSL expects")
out = struct.pack("<16f", *camera.view_proj)
out += struct.pack("<4f", *camera.position)
out += struct.pack("<4f", *camera.forward)
out += struct.pack("<4f", *camera.focus_point)
out += struct.pack("<3fi", camera.aperture, camera.focus_distance,
camera.depth_of_field, camera.projection_type)
return out
def pack_settings(camera: Camera, settings: Settings) -> bytes:
"""176 bytes: CameraStruct then three vec4-sized scalar groups."""
out = pack_camera(camera)
out += struct.pack("<f3i", settings.fog_effect, settings.itnum,
settings.gradient_resolution, settings.mark_area_in_focus)
out += struct.pack("<ifii", settings.warmup, settings.entropy,
settings.max_filter_radius, 0) # padding0
out += struct.pack("<i3f", settings.filter_method, settings.filter_param0,
settings.filter_param1, settings.filter_param2)
return out
def pack_iterators(iterators: list[Iterator]) -> bytes:
"""
`Iterator[MAX_ITERATORS]`, 48 bytes each.
The array is padded to its full declared length: std140 sizes the block from the
declaration, not from how many entries are live.
"""
if len(iterators) > MAX_ITERATORS:
raise ValueError(f"{len(iterators)} iterators exceeds MAX_ITERATORS")
out = bytearray()
for it in iterators:
out += struct.pack(
"<4f4ii2fi",
it.color_speed, it.color_index, it.opacity, it.reset_prob,
it.reset_alias, it.tf_id, it.real_params_index, it.vec3_params_index,
it.shading_mode, it.tf_mix, it.tf_add, 0) # padding2
out += bytes(48 * (MAX_ITERATORS - len(iterators)))
return bytes(out)
def pack_palette(colors: list[tuple[float, float, float, float]]) -> bytes:
"""`vec4[MAX_PALETTE_COLORS]`."""
if len(colors) > MAX_PALETTE_COLORS:
raise ValueError(f"{len(colors)} colors exceeds MAX_PALETTE_COLORS")
out = bytearray()
for c in colors:
out += struct.pack("<4f", *c)
out += bytes(16 * (MAX_PALETTE_COLORS - len(colors)))
return bytes(out)
def pack_real_params(values: list[float]) -> bytes:
"""
`float[MAX_PARAMS]` at std140's 16-byte stride.
The value is replicated across all four components, as the engine does. Only .x
is read; replicating simply matches the reference upload byte for byte.
"""
if len(values) > MAX_PARAMS:
raise ValueError(f"{len(values)} real params exceeds MAX_PARAMS")
out = bytearray()
for v in values:
out += struct.pack("<4f", v, v, v, v)
out += bytes(STD140_ARRAY_STRIDE * (MAX_PARAMS - len(values)))
return bytes(out)
def pack_vec3_params(values: list[tuple[float, float, float]]) -> bytes:
"""`vec4[MAX_PARAMS]`; the shader reads `.xyz`."""
if len(values) > MAX_PARAMS:
raise ValueError(f"{len(values)} vec3 params exceeds MAX_PARAMS")
out = bytearray()
for v in values:
out += struct.pack("<4f", v[0], v[1], v[2], 0.0)
out += bytes(16 * (MAX_PARAMS - len(values)))
return bytes(out)
def pack_frame(seed: int, width: int, height: int, dispatch_cnt: int,
reset_points_state: int, invocation_iters: int) -> bytes:
"""
`FrameParams`: the six former loose uniforms, declaration order, padded to 32.
std140 rounds a block up to a multiple of 16 and Blender rejects a buffer that
is not already padded, so the two trailing ints are real, not decorative.
"""
return struct.pack("<I7i", seed & 0xFFFFFFFF, width, height,
dispatch_cnt, reset_points_state, invocation_iters, 0, 0)
# ------------------------------------------------------------------ alias texture
def build_alias_tables(iterators: list[Iterator]) -> list[float]:
"""
The itnum x itnum alias texture, row-major by SOURCE iterator.
Mirrors `RendererGL.cs:515-545`: each row is the source's outgoing xaos weights
multiplied by the destinations' normalised base weights, renormalised, then
turned into an alias table. A row summing to zero means iteration RESETS there,
which the engine marks with (-1, -1) rather than a valid entry.
Returned flat as RGBA per texel: (u, k, 0, 0). The kernel fetches
`ivec2(i, iterator_index)`, so row = source, column = destination.
"""
n = len(iterators)
if n == 0:
return []
total_base = sum(it.base_weight for it in iterators)
if total_base <= 0:
normalized_base = [0.0] * n
else:
normalized_base = [it.base_weight / total_base for it in iterators]
texels: list[float] = []
for source in iterators:
weights = [source.weight_to.get(dst, 0.0) * normalized_base[dst]
for dst in range(n)]
total = sum(weights)
if total > 0:
table = generate_alias_table([w / total for w in weights])
else:
table = [(-1.0, -1)] * n
for u, k in table:
texels.extend((float(u), float(k), 0.0, 0.0))
return texels
def gradient(keys: list[tuple[float, float, float, float]],
resolution: int = MAX_PALETTE_COLORS
) -> list[tuple[float, float, float, float]]:
"""
Samples key colours into the flat gradient the kernel indexes.
`getPaletteColor` reads `palette[]` as a PRE-SAMPLED ramp of
`gradient_resolution` entries and lerps between neighbours - it is not a list
of key colours. Uploading the keys directly leaves every index past the last
key reading zero, which renders black while density accumulates normally.
"""
if not keys:
raise ValueError("need at least one key colour")
if len(keys) == 1:
return [keys[0]] * resolution
out: list[tuple[float, float, float, float]] = []
for i in range(resolution):
t = i / (resolution - 1) * (len(keys) - 1)
lo = min(int(t), len(keys) - 2)
f = t - lo
a, b = keys[lo], keys[lo + 1]
out.append(tuple(a[c] * (1.0 - f) + b[c] * f for c in range(4)))
return out
def expected_sizes() -> dict[str, int]:
"""What each block must measure, from the GLSL declarations."""
return {
"FrameParams": 32,
"SettingsParams": 176,
"IteratorsParams": 48 * MAX_ITERATORS,
"PaletteParams": 16 * MAX_PALETTE_COLORS,
"RealParams": STD140_ARRAY_STRIDE * MAX_PARAMS,
"Vec3Params": 16 * MAX_PARAMS,
}
# --------------------------------------------------------------- a demo document
def sierpinski() -> tuple[list[Iterator], Camera, Settings]:
"""
Three affine iterators forming a Sierpinski gasket, fully connected.
The smallest document that exercises the whole chain - iterator packing, xaos
alias tables, parameter indices - and whose correct output is recognisable at a
glance, so a broken render cannot look plausible.
Uses Affine, whose parameters are three vec3s in declaration order —
Translate, Scale, Rotate (Transforms/Affine.ifstf). All three must be supplied:
the plugin reads `@Scale` unconditionally, so omitting it would scale by zero.
The chaos game `p := (p + corner) / 2` becomes Translate = corner/2 with
Scale = 0.5, since Affine computes `Translate + p * rotate * scale`.
The caller supplies Affine's tfId from the compiled library, because tfId is an
index into THAT kernel's branch chain.
"""
corners = [(0.0, 0.75, 0.0), (-0.65, -0.375, 0.0), (0.65, -0.375, 0.0)]
iterators: list[Iterator] = []
for index, (cx, cy, cz) in enumerate(corners):
it = Iterator(
color_index=index / (len(corners) - 1.0),
color_speed=0.5,
opacity=1.0,
base_weight=1.0,
vec3_params_index=index * 3,
vec3_params=[
(cx / 2.0, cy / 2.0, cz / 2.0), # @Translate
(0.5, 0.5, 0.5), # @Scale
(0.0, 0.0, 0.0), # @Rotate
],
)
# Fully connected: every iterator may follow every other.
it.weight_to = {dst: 1.0 for dst in range(len(corners))}
iterators.append(it)
camera = Camera()
settings = Settings(itnum=len(iterators))
return iterators, camera, settings
def flatten_vec3_params(iterators: list[Iterator]) -> list[tuple[float, float, float]]:
"""Concatenates each iterator's vec3 params in `vec3_params_index` order."""
out: list[tuple[float, float, float]] = []
for it in sorted(iterators, key=lambda i: i.vec3_params_index):
if it.vec3_params_index != len(out):
raise ValueError(
f"vec3_params_index {it.vec3_params_index} does not follow the "
f"{len(out)} params packed so far")
out.extend(it.vec3_params)
return out
def flatten_real_params(iterators: list[Iterator]) -> list[float]:
"""Concatenates each iterator's real params in `real_params_index` order."""
out: list[float] = []
for it in sorted(iterators, key=lambda i: i.real_params_index):
if it.real_params_index != len(out):
raise ValueError(
f"real_params_index {it.real_params_index} does not follow the "
f"{len(out)} params packed so far")
out.extend(it.real_params)
return out