OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_hdri.py

246 lines · python ref:
"""
Bakes the fractal into an equirectangular environment map.

The fourth way of getting a flame out of the chaos game, and the one with the
fewest compromises — because the problem every other route fights simply does not
arise here.

An environment texture is **infinitely far away**. There is nothing to be occluded
by, so there is no depth compositing, no per-pixel occlusion question, and none of
the dithering that a cube sitting inside a nebula produces in the screen-space
overlay. Blender also lights the scene from it for free, in both EEVEE and Cycles,
which the overlay can never do.

## Why this is a small change

It reuses everything. `ifs_layout`'s equirect mode swaps only the PROJECTION —
each sample's direction from a centre, mapped to lat/long — and plots into the
ordinary 2D histogram. The drain, the auto-exposure and the tonemap are the same
code the viewport and the still render use, which is the point: the HDRI has to
look like the fractal, not like a third renderer that resembles it.

## Dynamic range — measured, and NOT what I first assumed

The image is float, but the values are NOT above 1.0: the shared tonemap ends with
`clamp(rgb, 0.0, 1.0)` (ifs_viewport, the last line of the fragment shader), so an
environment bake is bounded like every other output of that shader.

Measured on nebula_sample at 1024x512: mean 0.0154, p99 0.105, max 1.0, and
essentially 0% of pixels sitting AT 1.0. So the clamp is barely binding — the
range inside [0,1] is doing the work, and nothing is blowing out.

That costs less than it sounds for lighting, because the World's Strength input
multiplies the whole map: absolute brightness is recoverable, and the RATIO
between a bright core and faint haze — which is what actually shapes the light —
survives the clamp intact. Genuine >1 headroom would mean an unclamped path
through a tonemap shared with the viewport, which is a deliberate change to make
on purpose rather than a side effect of adding an HDRI.

## Animation

`seed` is derived from the FRAME, not from a dispatch counter. That is
load-bearing for animation: the kernel hashes its seed, so a counter that advanced
between renders would give a different sample set every time a frame was
re-rendered, and an animation would boil with noise that changes on every
re-render. Frame N must bake identically however many times it is baked. (The
viewport has the opposite requirement and the opposite bug history — see the
`dispatch_cnt` note in ifs_viewport.)
"""

from __future__ import annotations

import gpu
import numpy as np

import ifs_host
import ifs_render

# 2:1 is the equirectangular convention: u spans 360 degrees of azimuth, v spans
# 180 of elevation, so square pixels need twice the width.
DEFAULT_WIDTH = 2048
DEFAULT_HEIGHT = 1024


def build_shader(width, height, hist_scale=None):
    """Compiles the equirect variant of the kernel plus its drain."""
    if hist_scale is None:
        hist_scale = ifs_render.DEFAULT_HIST_SCALE
    return ifs_render._build_shaders(width, height, hist_scale, equirect=True)


def _document_buffers(doc):
    """The five per-document UBOs, as the other bake modes build them."""
    return {
        'settings': gpu.types.GPUUniformBuf(
            ifs_host.pack_settings(doc.camera, doc.settings)),
        '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))),
    }


def seed_for_frame(frame):
    """
    A stable per-frame seed.

    Any hash would do; what matters is that it is a pure function of the frame.
    Deriving it from a running counter is what makes an animation boil — the same
    frame re-rendered would draw a different sample set. The multiplier is an odd
    constant so consecutive frames are far apart in the kernel's hash.
    """
    return (int(frame) * 0x9E3779B1 + 0x85EBCA6B) & 0x7FFFFFFF


def bake_document(doc, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT,
                  dispatches=200, invocation_iters=500, frame=1,
                  centre=(0.0, 0.0, 0.0), brightness=None, shaders=None):
    """
    Bakes an ALREADY-LOADED document to an equirectangular float image.

    Takes the document rather than a path for the same reason `bake_document` and
    `emit_document` do: the panel and the node graph write into the live session's
    document, and anything reloading from disk would silently discard every edit
    made inside Blender.

    Returns `(pixels, stats)` — pixels (height, width, 4) float32, ROW 0 AT THE
    BOTTOM, which is Blender's image convention and saves a flip on the way in.
    Values ARE bounded to [0,1] by the shared tonemap; see the dynamic-range note
    above for why that costs less than it sounds.
    """
    gpu.init()
    if shaders is None:
        shaders = build_shader(width, height)
    kernel, drain, _library = shaders

    ubos = _document_buffers(doc)
    if brightness is None:
        brightness = doc.brightness * 8.0

    alias = ifs_host.build_alias_tables(doc.iterators)
    n = len(doc.iterators)
    alias_tex = gpu.types.GPUTexture(
        (n, n), format='RGBA32F',
        data=gpu.types.Buffer('FLOAT', len(alias), alias))

    histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI')
    histogram.clear(format='UINT', value=(0, 0, 0, 0))
    accum = gpu.types.GPUTexture((width, height), format='RGBA32F')
    accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
    rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W
    state = gpu.types.GPUTexture(
        (ifs_render.STATE_W * 2, rows), format='RGBA32F')
    state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))

    seed = seed_for_frame(frame)
    for index in range(dispatches):
        frame_ubo = gpu.types.GPUUniformBuf(ifs_host.pack_frame(
            seed=seed, width=width, height=height, dispatch_cnt=index,
            reset_points_state=1 if index == 0 else 0,
            invocation_iters=invocation_iters))
        kernel.bind()
        kernel.image('histogram_img', histogram)
        kernel.image('state_img', state)
        kernel.uniform_sampler('alias_tables_tex', alias_tex)
        kernel.uniform_float('equirect_centre', centre)
        kernel.uniform_block('frame', frame_ubo)
        for name, ubo in ubos.items():
            kernel.uniform_block(name, ubo)
        gpu.compute.dispatch(kernel, ifs_render.WORKGROUPS, 1, 1)

    drain.bind()
    drain.image('histogram_img', histogram)
    drain.image('accum_img', accum)
    drain.uniform_int('out_size', (width, height))
    gpu.compute.dispatch(drain, (width + 7) // 8, (height + 7) // 8, 1)

    pixels = _tonemap(accum, width, height, brightness)
    lit = float((pixels[:, :, :3].sum(axis=2) > 1e-4).mean())
    return pixels, {
        "width": width,
        "height": height,
        "dispatches": dispatches,
        "frame": frame,
        "seed": seed,
        "lit_fraction": lit,
        "peak": float(pixels[:, :, :3].max()),
    }


def _tonemap(accum, width, height, brightness):
    """
    The viewport's own auto-exposure and tonemap, rendered offscreen.

    Imported lazily because `ifs_viewport` pulls in `bpy`, and everything above
    this line runs without it.
    """
    from gpu_extras.batch import batch_for_shader
    import ifs_viewport

    maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
    maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
    maxpass = ifs_viewport.build_maxpass()
    maxpass.bind()
    maxpass.image('accum_img', accum)
    maxpass.image('max_img', maxbuf)
    maxpass.uniform_int('out_size', (width, height))
    gpu.compute.dispatch(maxpass, (width + 7) // 8, (height + 7) // 8, 1)

    # The tonemap samples a depth texture even in EMISSION mode. There is no depth
    # here and never will be, so it gets a 1x1 cleared to the FAR value — which the
    # shader reads as "nothing in front", exactly right for an environment.
    far = gpu.types.GPUTexture((1, 1), format='R32UI')
    far.clear(format='UINT', value=(0x3F800000, 0, 0, 0))

    colour = gpu.types.GPUTexture((width, height), format='RGBA32F')
    fb = gpu.types.GPUFrameBuffer(color_slots=(colour,))
    tonemap = ifs_viewport.build_tonemap()
    batch = batch_for_shader(tonemap, 'TRI_FAN',
                             {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
    with fb.bind():
        fb.clear(color=(0.0, 0.0, 0.0, 0.0))
        tonemap.bind()
        tonemap.uniform_sampler('accum_tex', accum)
        tonemap.uniform_sampler('max_tex', maxbuf)
        tonemap.uniform_sampler('depth_tex', far)
        tonemap.uniform_sampler('range_tex', far)   # 2 texels unused at 1 band
        tonemap.uniform_int('out_size', (width, height))
        tonemap.uniform_float('brightness', brightness)
        tonemap.uniform_int('shading_mode', (0,))     # EMISSION: a sky is light
        tonemap.uniform_float('opacity_gain', 4.0)
        tonemap.uniform_float('relief', 12.0)
        gpu.state.blend_set('NONE')                   # nothing to blend against
        batch.draw(tonemap)
        raw = fb.read_color(0, 0, width, height, 4, 0, 'FLOAT').to_list()
    return np.array(raw, dtype=np.float32).reshape(height, width, 4)


def to_image(pixels, name="IFS Environment"):
    """
    Writes the bake into a Blender float image, reusing one of the same name.

    Reused rather than replaced so that a World already pointing at this image
    keeps working — creating a new datablock every bake would leave the shader
    referencing the previous one and look like the bake did nothing.

    Measured at 2048x1024: `foreach_set` is 3.2 ms, so a per-frame update is not
    where the time goes.
    """
    import bpy

    height, width = pixels.shape[:2]
    image = bpy.data.images.get(name)
    if image is None or image.size[0] != width or image.size[1] != height:
        if image is not None:
            bpy.data.images.remove(image)
        image = bpy.data.images.new(name, width=width, height=height,
                                    float_buffer=True)
    image.colorspace_settings.name = 'Non-Color'
    image.pixels.foreach_set(np.ascontiguousarray(pixels, dtype=np.float32).ravel())
    image.update()
    return image