Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ consumed-input compositing contract, and a save/reload round-trip.
</tr>
<tr>
<td width="46%" valign="middle">
<a href="examples/light-link-studio/"><img src="examples/light-link-studio/preview.webp" alt="Light link studio: a blazing orange hero sphere over a LINKED plaque beside a dim steel decoy over UNLINKED on a dark studio floor - one key light restricted to the hero collection" /></a>
<a href="examples/light-link-studio/"><img src="examples/light-link-studio/preview.webp" alt="Light link studio: a blazing orange hero sphere beside a dim steel decoy on a dark studio floor, upright LINKED and UNLINKED plaques below each, a warm light pool raking the wall behind - one key light restricted to the hero collection" /></a>
</td>
<td valign="middle">

Expand All @@ -326,7 +326,7 @@ honors linking too (measured), with Cycles pinned for deterministic samples.
</tr>
<tr>
<td width="46%" valign="middle">
<a href="examples/vse-gamma-cross/"><img src="examples/vse-gamma-cross/preview.webp" alt="VSE gamma cross: a mixing bench in a dark studio - eight emissive panels fading crimson to teal across the cross, with the brighter naive-lerp midpoint framed in hazard orange on the console below" /></a>
<a href="examples/vse-gamma-cross/"><img src="examples/vse-gamma-cross/preview.webp" alt="VSE gamma cross: a mixing bench in a dark studio - four large emissive panels fading crimson to teal across the cross, with the true midpoint swatch beside the brighter naive-lerp midpoint framed in hazard orange at frame center" /></a>
</td>
<td valign="middle">

Expand Down
Binary file modified docs/gallery/assets/light-link-studio-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/vse-gamma-cross-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/contact-sheets/light-link-studio-contact-sheet.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/contact-sheets/vse-gamma-cross-contact-sheet.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -524,14 +524,14 @@ <h2><a href="vse-gamma-cross/">vse-gamma-cross</a></h2>
<a class="card-link" href="vse-gamma-cross/">View example <span aria-hidden="true">&rarr;</span></a>
</div>
</article>
<article class="card" data-tags="rendering">
<article class="card" data-tags="rendering objects">
<a class="card-media" href="light-link-studio/" aria-label="light-link-studio example detail page">
<img src="assets/light-link-studio-hero.webp" alt="light-link-studio — One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass." loading="lazy" decoding="async" />
</a>
<div class="card-body">
<h2><a href="light-link-studio/">light-link-studio</a></h2>
<p class="teaches">One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 4.0x luminance ratio; unlinked in the same check: the decoy rises 244% while the hero holds at 0.0% drift.</p>
<p class="witnesses"><span class="tag">witnesses</span> The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere&#x27;s projected center; EEVEE Next honors linking too (5.5x on 4.5, 5.9x on 5.1), Cycles pinned for deterministic samples.</p>
<p class="teaches">One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 3.6x luminance ratio; unlinked in the same check: the decoy rises 206% while the hero holds at 0.0% drift.</p>
<p class="witnesses"><span class="tag">witnesses</span> The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere&#x27;s projected center; EEVEE honors linking too (probe-measured 3.7x on 4.5.11 EEVEE Next and 5.1.2 EEVEE), Cycles pinned for deterministic samples.</p>
<a class="card-link" href="light-link-studio/">View example <span aria-hidden="true">&rarr;</span></a>
</div>
</article>
Expand Down
103 changes: 58 additions & 45 deletions docs/gallery/light-link-studio/index.html

Large diffs are not rendered by default.

130 changes: 68 additions & 62 deletions docs/gallery/vse-gamma-cross/index.html

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions examples/gallery.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,12 +361,13 @@
{
"name": "light-link-studio",
"dir": "examples/light-link-studio",
"teaches": "One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 4.0x luminance ratio; unlinked in the same check: the decoy rises 244% while the hero holds at 0.0% drift.",
"witnessesFix": "The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere's projected center; EEVEE Next honors linking too (5.5x on 4.5, 5.9x on 5.1), Cycles pinned for deterministic samples.",
"teaches": "One key, one hero: a light linked to a receiver collection lights only the hero, proven by two pixel renders in one pass. Linked: 3.6x luminance ratio; unlinked in the same check: the decoy rises 206% while the hero holds at 0.0% drift.",
"witnessesFix": "The API is obj.light_linking on the light OBJECT (ld.light_linking is an AttributeError). Luminance is sampled at each sphere's projected center; EEVEE honors linking too (probe-measured 3.7x on 4.5.11 EEVEE Next and 5.1.2 EEVEE), Cycles pinned for deterministic samples.",
"hero": "docs/gallery/assets/light-link-studio-hero.webp",
"preview": "examples/light-link-studio/preview.webp",
"tags": [
"rendering"
"rendering",
"objects"
]
}
]
Expand Down
49 changes: 28 additions & 21 deletions examples/light-link-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,43 @@ most often misses or misaddresses.

**What it witnesses:** the light-linking contract and the traps around it.

1. **The API is on the light OBJECT, not the Light datablock.**
`ld.light_linking` is an AttributeError on both versions (verified); the
path is `obj.light_linking.receiver_collection` (ObjectLightLinking, with
`blocker_collection` alongside). Assignment reads back through the API.
2. **Linked, the key lights only the receiver collection.** Render one:
hero well-lit, decoy at the fill floor — hero/decoy luminance ratio
**4.0x** (gate ≥ 3x), measured at each sphere's *projected* center so
framing can't move the sample off the subject.
3. **Unlinked (same check), the restriction vanishes surgically.** Render
two: the decoy rises **244–251%** while the hero drifts **0.0%** — the
link restricts without dimming anything else. The shipped check therefore
demonstrates both states, not just one.
4. **The engine note, verified rather than assumed.** Light linking also
works on EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and
5.9x on 5.1.2 EEVEE, matching Cycles within sampling noise. The check
still pins Cycles for deterministic tiny-sample CPU renders; luminance
gates need `view_transform = 'Standard'` (AgX compresses the ratios).
- **The API is on the light OBJECT, not the Light datablock.**
`ld.light_linking` is an AttributeError on both versions (verified); the
path is `obj.light_linking.receiver_collection` (ObjectLightLinking, with
`blocker_collection` alongside). Assignment reads back through the API.
- **Linked, the key lights only the receiver collection.** Render one:
hero well-lit, decoy at the fill floor — hero/decoy luminance ratio
**3.6x** (gate ≥ 3x), measured at each sphere's **projected** center so
framing can't move the sample off the subject.
- **Unlinked (same check), the restriction vanishes surgically.** Render
two: the decoy rises **206–213%** while the hero drifts **0.0%** — the
link restricts without dimming anything else. The shipped check therefore
demonstrates both states, not just one. The rise figure is noisy by
construction: the decoy's unlit base sits near the fill floor, so small
absolute shifts in that base swing the relative rise between versions and
runs — the gate is a conservative ≥ 50%.
- **The engine note, verified rather than assumed.** Light linking also
works on EEVEE — measured with an EEVEE luminance probe (the shipped
scene with `render.engine` swapped: `BLENDER_EEVEE` on 5.x,
`BLENDER_EEVEE_NEXT` on 4.x) at **3.7x** linked ratio on both 4.5.11
EEVEE Next and 5.1.2 EEVEE, matching Cycles within sampling noise. The
shipped check still pins Cycles for deterministic tiny-sample CPU
renders; luminance gates need `view_transform = 'Standard'` (AgX
compresses the ratios).

**What each check catches on failure:** inverting the link to the decoy
collection (exit 6 — hero drops to 0.179, ratio collapses to 0.22x), an
collection (exit 6 — hero/decoy ratio collapses to 0.39x), an
RNA move of the API onto the Light datablock (exit 3, guarded), a lost
assignment read-back (exit 5), a link that doesn't restrict (exit 7, rise
below 50%), and a non-surgical restriction (exit 8, hero drift above 5%).

**Version witness:** ObjectLightLinking API and the measured ratios match on
Blender 4.5 LTS and 5.1 (4.0x linked both; rise 244% vs 251% within sampling
noise).
Blender 4.5 LTS and 5.1 (3.4x vs 3.6x linked; rise 213% vs 206% — inside the
rise noise described above).

The render is the contract at a glance: the blazing orange hero over the
LINKED plaque, the cold steel decoy over UNLINKED — one key, one hero.
LINKED plaque, the cold steel decoy over UNLINKED, a warm pool raking the
wall behind them — one key, one hero.

## Run

Expand Down
85 changes: 49 additions & 36 deletions examples/light-link-studio/light_link_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
hero's is unchanged within tolerance — the link is surgical, not a dimmer.

The engine note, verified rather than assumed: light linking also works on
EEVEE Next — measured 5.5x linked ratio on 4.5.11 EEVEE Next and 5.9x on
5.1.2 EEVEE, matching Cycles within sampling noise. The check still pins
Cycles for deterministic tiny-sample CPU renders. The API is stable between
Blender 4.5 LTS and 5.1 (ObjectLightLinking with
EEVEE — measured with an EEVEE luminance probe (this scene, only
``render.engine`` swapped: ``BLENDER_EEVEE`` on 5.x, ``BLENDER_EEVEE_NEXT``
on 4.x) at 3.7x linked ratio on both 4.5.11 EEVEE Next and 5.1.2 EEVEE,
matching Cycles within sampling noise. The shipped check pins Cycles for
deterministic tiny-sample CPU renders. The API is stable between Blender
4.5 LTS and 5.1 (ObjectLightLinking with
receiver_collection/blocker_collection on both).

By default it runs the two-render correctness check (no gallery still) — the
Expand All @@ -26,11 +28,10 @@
"""
import bpy, bmesh, sys, os, math, argparse, tempfile, shutil

IS_5X = bpy.app.version >= (5, 0, 0)
HERO_RGB = (0.85, 0.30, 0.08) # hazard orange
DECOY_RGB = (0.30, 0.34, 0.40) # cold steel
KEY_ENERGY = 450.0
FILL_ENERGY = 18.0
KEY_ENERGY = 560.0
FILL_ENERGY = 26.0
PXW, PXH = 64, 36
RATIO_MIN = 3.0 # hero/decoy luminance ratio, linked
HERO_LUMA_MIN = 0.25 # hero must be well-lit, linked
Expand All @@ -52,22 +53,28 @@ def sphere(name, rgb, x, coll):
mat.use_nodes = True
b = mat.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (*rgb, 1.0)
b.inputs["Roughness"].default_value = 0.45
b.inputs["Roughness"].default_value = 0.55
me.materials.append(mat)
ob = bpy.data.objects.new(name, me)
ob.location = (x, 0.0, 0.55)
coll.objects.link(ob)
return ob


def add_light(sc, name, energy, loc, rot, color, size=1.2):
def add_light(sc, name, energy, loc, color, size, aim=None, rot=None):
ld = bpy.data.lights.new(name, 'AREA')
ld.energy = energy
ld.size = size
ld.color = color
ob = bpy.data.objects.new(name, ld)
ob.location = loc
ob.rotation_euler = tuple(math.radians(a) for a in rot)
if aim is not None:
con = ob.constraints.new('TRACK_TO')
con.target = aim
con.track_axis = 'TRACK_NEGATIVE_Z'
con.up_axis = 'UP_Y'
else:
ob.rotation_euler = tuple(math.radians(a) for a in rot)
sc.collection.objects.link(ob)
return ob

Expand Down Expand Up @@ -106,30 +113,40 @@ def build_studio(sc):
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
sc.world = world

key = add_light(sc, "Key", KEY_ENERGY, (-2.2, -2.5, 3.5), (40, 0, -25),
(1.0, 0.92, 0.82), size=1.4)
add_light(sc, "Fill", FILL_ENERGY, (3.0, -2.0, 2.2), (60, 0, 35),
(0.75, 0.85, 1.0), size=4.0)
add_light(sc, "Rim", 120.0, (0.5, 4.5, 4.0), (-55, 0, 175),
(0.6, 0.78, 1.0), size=3.0)
# the signature warm wedge raking the back wall (docs/VISUAL-STYLE.md)
add_light(sc, "Wedge", 250.0, (-2.5, 3.5, 4.2), (-72, 0, 165),
(1.0, 0.76, 0.5), size=5.0)
# every subject light is aimed at the aim empty, so no cone grazes the
# floor/wall seam into a bright band (docs/VISUAL-STYLE.md)
aim = bpy.data.objects.new("Aim", None)
aim.location = (0.0, 0.0, 0.4)
sc.collection.objects.link(aim)

key = add_light(sc, "Key", KEY_ENERGY, (-3.0, -3.0, 4.0),
(1.0, 0.96, 0.9), size=4.5, aim=aim)
add_light(sc, "Fill", FILL_ENERGY, (3.2, -1.6, 1.8),
(0.75, 0.85, 1.0), size=4.5, aim=aim)
add_light(sc, "Rim", 200.0, (0.5, 4.5, 4.0),
(0.6, 0.78, 1.0), size=2.5, aim=aim)
# the signature warm wedge: between the subjects and the wall, raking the
# wall low so a soft warm pool sits behind the hero just above the seam
add_light(sc, "Wedge", 500.0, (-2.0, 7.0, 2.8),
(1.0, 0.76, 0.5), size=5.0, rot=(-49, 0, 166))

cam = bpy.data.cameras.new("Cam")
cam.lens = 55.0
cam.lens = 50.0
cam_ob = bpy.data.objects.new("Cam", cam)
cam_ob.location = (0.0, -5.9, 1.32)
cam_ob.rotation_euler = (math.radians(86), 0.0, 0.0)
cam_ob.location = (0.0, -6.2, 2.0)
con = cam_ob.constraints.new('TRACK_TO')
con.target = aim
con.track_axis = 'TRACK_NEGATIVE_Z'
con.up_axis = 'UP_Y'
sc.collection.objects.link(cam_ob)
sc.camera = cam_ob
return hero, decoy, hero_c, key


def setup_render(sc, w, h, samples):
# Cycles for deterministic tiny CPU samples; EEVEE Next honors linking
# too (measured on both supported versions), but its sampling is not
# deterministic enough for a luminance gate
# Cycles for deterministic tiny CPU samples; EEVEE honors linking too
# (measured with an authoring probe on both supported versions), but
# its sampling is not deterministic enough for a luminance gate
sc.render.engine = 'CYCLES'
sc.cycles.samples = samples
sc.cycles.use_denoising = False
Expand Down Expand Up @@ -228,33 +245,31 @@ def check(sc, key, hero_c, hero, decoy):
return 0


def eevee_engine_id():
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'


def plaque(sc, text, x):
pm = bpy.data.materials.new("PlaqueMetal")
pm.use_nodes = True
pb = pm.node_tree.nodes["Principled BSDF"]
pb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0)
pb.inputs["Metallic"].default_value = 0.2
pb.inputs["Roughness"].default_value = 0.6
# faint self-glow: the LINKED plaque must read even in the decoy's dark
# faint self-glow: the UNLINKED plaque must read even in the decoy's dark
sock = pb.inputs.get("Emission Color") or pb.inputs["Emission"]
sock.default_value = (0.35, 0.37, 0.42, 1.0)
pb.inputs["Emission Strength"].default_value = 0.6
cu = bpy.data.curves.new("Plaque", 'FONT')
cu.body = text
cu.align_x = 'CENTER'
cu.size = 0.20
cu.size = 0.22
cu.extrude = 0.006
ob = bpy.data.objects.new("Plaque", cu)
ob.location = (x, -0.68, 0.01)
# standing upright on the floor, tilted back to face the raised camera
ob.location = (x, -1.3, 0.17)
ob.rotation_euler = (math.radians(76), 0.0, 0.0)
ob.data.materials.append(pm)
sc.collection.objects.link(ob)


def render_still(sc, path, engine):
def render_still(sc, path):
plaque(sc, "LINKED", -1.2)
plaque(sc, "UNLINKED", 1.2)
# the gallery still is the linked state, on Cycles for the same
Expand All @@ -276,8 +291,6 @@ def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
p = argparse.ArgumentParser()
p.add_argument("--output", default=None, help="optional: render a still PNG here")
p.add_argument("--engine", default="cycles", choices=("cycles",),
help="render engine — pinned to cycles for deterministic samples")
args = p.parse_args(argv)

bpy.ops.wm.read_factory_settings(use_empty=True)
Expand All @@ -288,7 +301,7 @@ def main():
return code

if args.output:
if not render_still(sc, os.path.abspath(args.output), args.engine):
if not render_still(sc, os.path.abspath(args.output)):
print("ERROR: render produced no file", file=sys.stderr)
return 9
print(f"rendered still {args.output}")
Expand Down
Binary file modified examples/light-link-studio/preview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 26 additions & 20 deletions examples/vse-gamma-cross/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@ code assumes the cross is the naive linear mix, and it is not.

**What it witnesses:** the fade math and the frame convention behind it.

1. **The cross blends in a gamma-0.5 space.** Not `(1-t)·A + t·B` but
`((1-t)·√A + t·√B)²` — the mid-cross dips below the sRGB lerp. From crimson
`(0.85, 0.10, 0.22)` and teal `(0.06, 0.75, 0.80)` the midpoint is
`(0.341, 0.349, 0.463)`: **0.115 darker** on red than the lerp
`(0.455, 0.425, 0.510)`. The check asserts every sample within 5e-3
(2× the 8-bit quantization step + fit residual; measured 2.93e-3) and that
the mid lerp deviation is material (≥0.05).
2. **`t = (frame − start) / duration`, and it never reaches 1 inside the
effect.** The last frame of the span blends at `(duration−1)/duration`;
B arrives only when the effect ends. An endpoint-inclusive convention is
off by a full frame-step (probe: exit 5 at t 0.129 vs 0.125).
3. **The pixel witness demands `view_transform = 'Standard'`** — the factory
default AgX tone-maps the samples and poisons the fit (measured 0.146 on
the red channel during authoring), the same class of silent failure the
gallery's render standard exists to prevent. Also caught: deleting a
consumed input strip orphans-and-deletes the effect (`Strip 'GC' not in
scene`) — remove effects before their inputs.
- **The cross blends in a gamma-0.5 space.** Not `(1-t)·A + t·B` but
`((1-t)·√A + t·√B)²` — the mid-cross dips below the sRGB lerp. From crimson
`(0.85, 0.10, 0.22)` and teal `(0.06, 0.75, 0.80)` the midpoint is
`(0.341, 0.349, 0.463)`: **0.115 darker** on red than the lerp
`(0.455, 0.425, 0.510)`. The check renders and asserts nine samples
(t = 0, 1/8, …, 7/8, 31/32) within 5e-3
(2× the 8-bit quantization step + fit residual; measured 2.93e-3) and that
the mid lerp deviation is material (≥0.05).
- **`t = (frame − start) / duration`, and it never reaches 1 inside the
effect.** The last frame of the span blends at `(duration−1)/duration`;
B arrives only when the effect ends. That final frame (frame 32,
t = 31/32) is one of the nine asserted samples, so the convention is
pixel-backed end to end. An endpoint-inclusive convention is off by a
full frame-step (probe: exit 5 at t 0.129 vs 0.125).
- **The pixel witness demands `view_transform = 'Standard'`** — the factory
default AgX tone-maps the samples and poisons the fit (measured 0.146 on
the red channel during authoring), the same class of silent failure the
gallery's render standard exists to prevent. Also caught: deleting a
consumed input strip orphans-and-deletes the effect (`Strip 'GC' not in
scene`) — remove effects before their inputs.

**What each check catches on failure:** asserting the naive lerp as the
expectation (exit 6, deviation 0.1138 at mid), the endpoint-inclusive t
Expand All @@ -34,9 +37,12 @@ every sample matches to the quantization step. The creation contract from
`vse-cut-list` still gates the timeline: `strips` (never `.sequences`), and
`new_effect` ending in `length=` on 5.x vs `frame_end=` on 4.5.

The render is a mixing bench: the eight authentic cross samples as emissive
panels fading crimson to teal, with the naive lerp midpoint framed in hazard
orange below — visibly brighter than the true mid, the gamma dip made visible.
The render is the bench: the authentic fade as four large emissive panels
(t = 0, 1/4, 1/2, 3/4) above the contrast pair at frame center — the true mid
swatch directly beside the naive lerp mid, the impostor framed in hazard
orange. The gamma dip reads as an adjacency contrast even at card scale: a
naive-lerp cross would make the pair identical, so the still visibly breaks
with the contract.

## Run

Expand Down
Binary file modified examples/vse-gamma-cross/preview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading