OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_host.py

388 lines · python ref:
"""
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