"""
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