Claude Skill

scenario-blender-lighting-rendering

Use when lighting, rendering or compositing in Blender: light a character, product or hero shot, interior at dusk or night, three-point or motivated lighting, sun and sky, HDRI, EEVEE vs Cycles settings, light leaks or noise, flat or muddy renders, subject not standing out, AgX/A

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-lighting-rendering-dc592af.zip · 68 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-lighting-rendering
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Lighting, rendering and compositing

Expert lighting controls where the eye goes. Shape comes from gradients, the subject is separated from its surroundings by value (checked desaturated), every light has a source and a purpose, and every decision is measured on the render, not on lamp values. Work from a controlled base, change one thing at a time, and compare. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

REQUIRED BACKGROUND: scenario-blender-expert (execution channel, review loop, 5.2 API changes). Related: scenario-blender-texturing-shading (materials, skin SSS), scenario-blender-previs-storyboard (shot planning, cameras).

Toolkit: scripts/bx_light.py (tested on 5.2.1): rigs placed relative to subject and camera, EEVEE/Cycles presets, color setup and audit, 5.x compositor helpers, render analysis. Import: sys.path.append("<this skill>/scripts"); import bx_light as BL.

Stance (the expert delta)

  • Value separation first. Andy Goralczyk separates character from background by value in almost every shot and checks it desaturated. Andrew Price: the highest-contrast area becomes the focal point whether you want it or not (the bright window beats the couch). Fix by darkening the set before brightening the subject.
  • Light size and distance are the controls. Andrew: a large source reads form and hides pores, a small one reads texture and grit, so a face key is a large source, never a small bulb (unless toon or deliberately harsh). Inverse square is an attention tool: move a light closer instead of adding helpers. No key:fill ratio is a rule (Andrew: "half the key is bogus"): dim the shadow side until it reads as shadow with detail, and measure on the subject.
  • Motivate, then cheat plausibly. Every light needs a source and a color from the world (Gleb Alexandrov, Lino Thomas). Andy puts the rim on the key side, because on the shadow side it reads as a bounce from nowhere. When the source is behind the subject he fakes a key into the face. Shadow casters and light linking keep cheats off the set. Gleb stops before the image "looks lit".
  • EEVEE is a trench coat (Andrew). Ray tracing is off in a factory scene. World light passes through walls unless the volume probe's outer samples sit at the inner walls (Andrew; measured 0.26 leak to 0.002; 5.2.1 puts samples one spacing inside the box, so size the box from the points). Soft lights need per-light jitter (Gleb) and custom distance (measured: automatic distance loses 8 %).
  • Cycles economy (Andy, Sprite Fright): 3 bounces, no caustics, no clamping, Fast GI, and bounces faked with lights. Keep sharp speculars for the eyes and hero metal only.
  • Linear and wide until the end (Jacob Holiday). EXR stays linear while PNG bakes the view transform. AgX hides clipping (linear 4 displays 0.91), so read the EXR. Global brightness is exposure in the view transform, not scaled lights (keeps every ratio, including world and emissives). AgX for scenes, Khronos PBR Neutral for product color. Set the working space once (Nathan Vegdahl).
  • Rebalance in comp, not by re-rendering (Wyatt, Robin). In Cycles, light groups per role (key, fill, rim, window): one render, then gains in the compositor (BL.light_groups, BL.lightgroup_mix). EEVEE has no light-group outputs: use light linking and view layers.
  • Always comp, lightly (Pau Homs): bloom, vignette, then a correction, stopping before it reads as a filter. In 5.x the compositor is a node group.
  • Color has three contrasts (Lino): value, saturation and hue. Keep one saturated focus hue and desaturate its complement over large areas.

Establish first

Input Why it changes the plan Default when silent
Still or animation Continuity, flicker and temporal denoise Still
Engine EEVEE: stylized, fast iteration, volumes, NPR, viewport comp with passes. Cycles: glass close-ups, bounce realism, reference EEVEE to iterate, plus a Cycles reference render
Style Toon terminators need a small, unjittered key Stylized-realistic
Subject and focus point Rig target (the face), mask for the analysis Character head, else bbox center
Time and motivation Color scheme: warm practicals, cool sky, sun Night/dusk interior: warm key (2,700 K [added]), cool fill
Delivery PNG display plus EXR master; external grade? PNG + 32-bit multilayer EXR
Color fidelity Brand RGB: Khronos PBR Neutral; skin: ACES 2.0 or AgX High Contrast AgX
Late balance changes, comp hand-off Cycles light groups per role, multilayer EXR groups when rendering in Cycles

Workflow

  1. Compose, then lock the camera. Lighting is view dependent (Andrew). Use BL.screen_xy and BL.aim_camera_at_frame_point(head, 1/3 or 2/3, 0.62) for look room, and 80 mm for boxy products. GATE: one focal element; subject near a thirds point or a deliberate symmetry, pyramid or full frame.
  2. Controlled base. For a studio: world strength 0 and a near-black rough ground (Andrew). For an EEVEE interior: pb = BL.volume_probe_for_room(inner_lo, inner_hi, cell=0.5, wall=0.2) (outer samples on the inner walls), BL.probe_report(pb, inner_lo, inner_hi), then BL.bake_probes(). For an exterior: BL.outdoor_sun_sky(..., azimuth_space="world"). GATE: probe_report flags nothing and a world-only render of a sealed interior is near black.
  3. Key. Use BL.three_point, BL.motivated_interior (window + practical, cheated linked key, spot practical, rims, bounce) or BL.outdoor_sun_sky. Lights stay out of walls automatically. GATE: BL.light_report(target=head, face=head) has no flags (frontal key, key below the eye line, key smaller than the head), and a clay render (BL.clay(True)) gives internal_ratio >= 2.5.
  4. Supporting lights. Add fill, rim, bounce and BL.eye_light, plus BL.shadow_caster, BL.fake_sun, BL.split_world or BL.haze as needed. Linking lives on the light OBJECT (obj.light_linking, via BL.link_light), not the Light datablock. Adjust one lamp at a time: BL.light_contributions(lights, mask, dir), or in Cycles BL.lightgroup_check(exr, mask) from one render. GATE: the key has the largest share on the subject, the world is under 10 % indoors, and each rim is motivated.
  5. Measure and look. r = BL.render(base) gives PNG + EXR. Then mask = BL.mask_cryptomatte(r["exr"], names) (or BL.mask_workbench), rep = BL.analyse(r["png"], mask, exr=r["exr"]), BL.verdict(rep) and BL.value_study(...), and open the images. GATE: the verdict is empty and in the value study the subject owns a value group; otherwise darken the set, carve with spots or blockers, and link the key.
  6. Color. BL.color_setup(view, look, exposure) (brightness here, never by scaling lamps) and BL.color_audit(). GATE: the audit is empty, no complementary clash is flagged, clip is at most 0.5 % outside emitters, and the linear max is intended.
  7. Render settings. BL.preset_eevee(quality="final", interior=...) or BL.preset_cycles(quality="final", budget="film"). For EEVEE finals, render a Cycles preview reference (Andrew). GATE: the subject is within about 30 % of Cycles and there are no engine artifacts (critique section 7).
  8. Comp. Cycles: mix = BL.lightgroup_mix(gains, exr=r["exr"]) re-grades key/fill/rim/window from the EXR in about 0.2 s (BL.set_gain, RGB gains recolor). Then BL.comp_finish(bloom=0.25, vignette=0.3, contrast=0.05, source=mix["socket"]); masks with BL.matte(tree, names); pixel sizes through BL.relative_px; passes, back to beauty and view layers are in procedures P13. GATE: re-run the verdict on the finished frame; the vignette ratio should fall.
  9. Deliver with evidence: final PNG, EXR, value study, the light table, the verdict, and what was not verified.

Animation: pin the sun per location with sun_azimuth within about 30 deg (Andy). Light the wide shot and propagate it to coverage. Check shots with BL.contact_sheet and BL.sheet_stats (outliers more than 0.5 stop from the median). Keep temporal denoise on and never use ray-tracing resolution 1/16.

Numbers

Value Relative to Source
Level of a white card: point P/(4π²d²), area P/(π²d²), sun S/π BL.energy_for measured, both engines
Source as big as its distance delivers 75 % of the nominal level apparent size measured
AgX display: 0.18 to 0.463, 1.0 to 0.773, 4.0 to 0.910 linear value measured
Key 40 deg off axis, 35 deg up, 3 subject radii, size = radius subject, camera [added] defaults
Frontal flag: key < 20 deg off the camera axis camera axis [added]
Sun angle about 2 deg; continuity ±30 deg set Andy
Kelvin: candle 1,500, incandescent 2,000 to 2,500, daylight 6,500 (practical default 2,700 [added]) light Andrew
EEVEE: RT 1:1 and GI 1:2 for finals, Fast GI steps 16, shadow steps 12 to 16 (leaks), shadow rays 2 (penumbra) render Andrew
Volume probe: outer samples on the inner walls (samples at local (i+1)/(N+1)x2-1); leak 0.22 at 0.44 m inside, 0.054 at 0.2 m, 0.002 at the wall, 0.009 past the wall; surfel density 100 room Andrew, measured
Brand color error (of 255): Khronos 3.8, AgX 27 to 42, Standard 33 white furnace measured
Haze density about 0.05 for a room volume Andrew
Cycles film: 3 bounces, clamp 0, no caustics, 100 test / 500 to 1,500 final samples render Andy
EXR: multilayer, half float, DWAA for comp; 32-bit masters output Robin, Andy
Verdict: surround separation ≥ 0.10, edge merge ≤ 35 %, clay form ratio ≥ 2.5, value range ≥ 0.30, clip ≤ 0.5 % display luma [added], calibrated

Quality gates

Measurable:

rep = BL.analyse(png, mask, exr=exr); problems = BL.verdict(rep)          # [] = pass
flags = BL.light_report(target=head, face=head)["flags"]; audit = BL.color_audit()
probe = BL.probe_report(pb, inner_lo, inner_hi)["flags"]                  # EEVEE interiors: []
groups = BL.lightgroup_check(exr, mask)                                   # Cycles: rel_error ~0, shares
shares = BL.light_contributions(lights, mask, out_dir)                   # per-light share on subject

Visual: open the render, the value_study panel (desaturated | value groups | squint) and, for sequences, the contact sheet. Judge them with references/critique.md: first read, value structure, form, motivation, color, composition, artifacts, finish.

Common mistakes

Mistake What it looks like Fix
Frontal key Flat, "flash photo", clay form ratio about 1.3 Key 30 to 60 deg off axis, above the eye line
Practical behind the subject used as the key Dull face, bright wall steals focus Cheated key on the source's side, linked to the subject
Bare point practical near a wall Blown wall, shelf becomes the focal point Spot with the shade's cone; blocker
EEVEE interior without probe, or a probe box drawn to the walls Shadows lifted blue/gray everywhere, muddy Outer samples at the inner walls (probe_report), bake, sealed-room test
Small bulb as a face key Pores and texture, harsh face Large source (head-sized or more); flag in light_report(face=)
Brightening by scaling lamps World and emissives left behind, ratios drift exposure in the view transform
light.data.light_linking AttributeError Linking and light groups are Object properties
Re-rendering to rebalance lights Hours per tweak Cycles light groups + lightgroup_mix
Rig lights inside walls A rim that contributes nothing avoid=subject (default in rigs)
Unlinked faked key Set brightens with the subject, no separation link_key=True; key share high, background share near 0
Judging clipping on AgX PNG Highlights look fine, EXR at 20+ Read linear_max, use exposure
Big soft jittered key on a toon material Dithered, noisy terminator Small unjittered key for NPR (Wyatt: crisp shadows for toon)
Inverted hull outline Character goes dark or flat Outline material transparent to shadow rays (outline_hull)
AOV named in shader only Pass silently missing BL.register_aovs()
Pixel-sized comp effects Look changes between preview and final BL.relative_px
Exclude instead of indirect-only in layers Missing shadows in the comp indirect_only (0.0004 vs 0.016 diff)

Blender 5.2 notes

  • EEVEE id is BLENDER_EEVEE; use_raytracing is False by default; use_bloom/ssr/gtao/soft_shadows are gone (Glare node, ray tracing, Fast GI, per-light jitter).
  • Probes are SPHERE/PLANE/VOLUME; lightprobe_cache_bake runs synchronously headless; the sphere-probe default is 512. Volume samples sit one spacing inside the box faces, never on them.
  • EEVEE writes Cryptomatte but no Object Index pass; light groups are Cycles only (EEVEE Render Layers has no Combined_<group>); light linking works in both engines. light_linking and lightgroup are Object properties; bpy.types.Light has neither.
  • Compositor: scene.compositing_node_group, Group Output, ShaderNodeMix (color sockets 6/7, result 2), menu inputs take display names ('Bloom'). GPU is the default device. Assets live in datafiles/assets/nodes/compositing_nodes_essentials.blend.
  • Color: the working space is read-only (use bpy.ops.wm.set_working_color_space); looks are prefixed ('AgX - Punchy'); 'Linear'/'Raw' are rejected; camera log spaces exist; Principled Thin Wall exists for window glass.
  • matrix_world is stale after setting a transform; call view_layer.update(). OIIO read_image(subimage, ...): the first argument selects the part.

References

  • references/procedures.md: tested code for every stage (P1 units to P17 exposure and color), with measured results and test paths. Load before writing code.
  • references/critique.md: the rubric to judge a render or sequence, in expert reading order, with the code check behind each question.
  • references/expert-notes.md: principles per expert with timestamps, plus the disagreements table. Load for judgment calls and attribution.
  • references/sources.md: the 15 videos, credentials, best timestamps, and which to consult for which question.
Files (skills)
  • references
    • critique.md 7.8 KB
      # Critique rubric: judge a render like a lighter
      
      Run the numbers first (`BL.analyse` + `BL.verdict`, `BL.light_report`, `BL.color_audit`), then open the render, the value study and, for sequences, the contact sheet, and answer the questions in this order (the order experts read a frame). A number that passes does not excuse a visual failure; a visual doubt with passing numbers means look closer at that region. Thresholds are [added] starting points calibrated on the workshop fixture (procedures P9).
      
      Severity: **blocker** (the image does not read; fix before anything else), **major** (reads, but an expert would send it back), **minor** (polish).
      
      ## 1. One-second read (blocker)
      
      - Where does the eye go first? It must be the subject's face or the story point. Andrew: the highest-contrast area wins whether you want it or not [O8i7OKbWmRM 00:04:00].
        - Code: `rep["focus"]["peak_on_subject"]`, `concentration >= 1.5`; `rep["squint"]["max_on_subject"]`.
        - Fix: darken or shadow the competitor (blocker, spot cone, split world, lower that light), add a cue on the subject (light-linked key, saturation, DoF), or move the camera.
      - Is there exactly one focal element? Two equal ones irritate [O8i7OKbWmRM 00:11:00].
      
      ## 2. Value structure, desaturated (blocker)
      
      - Open `value_study`: does the subject sit in a different value group than what surrounds it? Andy checks almost every shot desaturated [APgQKQgAdyU 00:22:09].
        - Code: `subject.minus_ring` magnitude >= 0.10, `band != ring_band`, `edge_merge_pct <= 35`.
        - Fix order: darken the set first (Andy [00:53:02]; linking the key to the subject, spot instead of point, blockers, lower window/practical), then raise the subject; for a dark subject, light the background behind the dark side instead.
      - Are there 3 to 5 clear value groups, with the foreground in high contrast and the background in midtones (Lino [Xqne7oRxSjM 00:17:45])? `value_range` spread >= 0.30.
      - Are the frame edges darker than the center (vignette in the lighting, Andy [00:23:18], Lino [00:19:25])? `vignette_ratio < 1`.
      
      ## 3. Form and light quality (major)
      
      - Is there a light-to-shadow gradient across the subject, not flat front light? Gleb: "flattening, not flattering" [6hPZ0ckL5I0 00:14:48]; Andy: gradients along the faces [00:20:28].
        - Code: clay render `subject.internal_ratio >= 2.5` (flat frontal key measured 1.34, designed key 6.05); `light_report` flags a key < 20 deg off the camera axis or below the eye line.
      - Does the light size match the intent? Large source for faces and form, small for texture, grit, hard sun or streetlamp (Andrew [ENnEYoUpFfU 00:19:17] [00:22:34]). Check `angular_size_deg` and `source_m` in `light_report`; `light_report(face=head)` flags a key smaller than the head (not an issue for toon keys).
      - Does the shadow side read as shadow and still show detail (Andrew [00:04:45])? Judge the result, not a key:fill ratio: Andrew calls the textbook ratio "bogus".
      - Soft lights in EEVEE: microshadows under nose, chin, eyelids present (per-light jitter, Gleb [00:05:55])? Skin not resin-like (SSS radius, Gleb [00:04:24])?
      - Protrusion vs cutout readable; major planes in different values (Andrew [00:03:09] [00:33:37]).
      
      ## 4. Motivation and plausibility (major)
      
      - Can every light be named by its purpose and its source (Andrew [00:34:40])? Are colors drawn from the world (window = sky color, practical = tungsten, rim eyedropped from the background: Gleb [00:17:36], Lino [00:30:45])?
      - Rim on the key side, or on the shadow side only with a visible or implied source behind (Andy [00:53:36])?
      - Does it "look lit" (Gleb [00:09:42])? Cheats (cheated key, blockers, split world) must stay plausible.
      - Eye highlights in the upper half of the eye, only on the eyes, not cross-eyed (Andy [00:34:56]; Gleb eye light linking [00:20:35]).
      - Speculars restricted to the eyes and hero metal; no background twinkle (Andy [00:24:23]).
      - Code: `light_contributions`: the key carries the largest share on the subject; in interiors the world is < 10 % (a larger share usually means the EEVEE world leak). In Cycles with light groups, one render gives the same shares: `lightgroup_check(exr, mask)` (S7 workshop: linked key 0.77 on the character, 0.28 of the frame); a group that must be turned up or down is a comp gain (`lightgroup_mix`), not a re-render.
      
      ## 5. Color (major)
      
      - Contrast in value, saturation and hue all present (Lino [00:01:06]).
      - One saturated focus hue; the complement desaturated over large areas or only in small dots (Lino [00:07:40]).
        - Code: `rep["hue"]["clusters"]`; the verdict flags two saturated complements over similar areas.
      - Warm/cool separation matches the motivation (practical warm on the subject, cool window fill).
      - Color pipeline: `color_audit()` empty (data maps Non-Color, not Standard for HDR scenes, finals Follow Scene, background footage View as Render). View transform chosen on purpose: AgX for scenes, ACES 2.0 or AgX High Contrast for portraits, Khronos PBR Neutral for product or brand colors (measured: brand red within 3.8 of 255 steps vs AgX 41.8). Global brightness changed with `exposure`, never by scaling lamps (the world and emissives get left behind).
      - Clipping: `clip_pct <= 0.5` outside emitters; read `linear_max` from the EXR (AgX shows linear 4 as 0.91). Crushed blacks `crush_pct <= 25` (near-black keeps headroom, Andrew [00:10:05]).
      
      ## 6. Composition (major)
      
      - Structure chosen and respected: subject near a thirds point (`thirds.distance < 0.1`) with look room, or deliberate symmetry, pyramid or full frame (Andrew [O8i7OKbWmRM 00:14:00]).
      - Balance: `squint.left_weight` near 0.5, or a counterweight on the light side (figures and shadows count, [00:26:00]).
      - No guiding line leaves the frame without a stopper [00:10:30]; high-weight cells on the border (`squint.edge_weight`) checked visually.
      - Lens: no distortion on boxy products (80 mm, Andrew [ENnEYoUpFfU 00:32:32]).
      
      ## 7. Engine artifacts (major for finals, minor for previews)
      
      - EEVEE: ray tracing on; black ghosts behind objects in reflections, reflections eaten at frame edges, light switching on/off when the camera pans, grid patterns or banding after a probe bake, smeared textures (temporal accumulation), glass portals or self-reflection (sphere probe clip start), light leaks through thin walls (Andrew, -gW6vk_OuNQ). For interiors, a world-only render with windows closed must be near black, and `probe_report` must show the outer probe samples at the inner walls (Andrew [00:38:22]; samples 0.2 m inside leak 0.054).
      - EEVEE vs Cycles: render a Cycles reference at preview quality; on the S7 fixture EEVEE matched the character (0.94) but lifted the set (1.54). Judge the set's darkness against Cycles.
      - Cycles: fireflies (clamp or fix the path), denoiser blotches in dark corners, noise in soft shadows and volumes; bounces enough for glass (8 transmission).
      - Animation: flicker (jitter, alpha foliage, RT resolution 1/16), temporal denoise on.
      
      ## 8. Comp finish (minor)
      
      - Bloom only where values exceed ~1.0 (`over1_pct` small, on intended highlights), vignette subtle, correction restores the punch; stop before "2013's Instagram" (Pau [kAWfjBKcgFc 00:06:32]).
      - Pixel-unit effects driven by Relative To Pixel, so previews match finals (Pau [00:11:54]).
      - Re-run `verdict` on the finished image: the comp must not break separation.
      
      ## 9. Sequences (major)
      
      - Sun azimuth per location within about 30 deg of the reference (`BL.sun_azimuth`, Andy [00:29:30]).
      - Contact sheet reads as one film; `sheet_stats` flags shots more than 0.5 stop from the median (Andy [00:38:59]).
      - Eye-highlight direction consistent with the key per shot (Andy's cheat sheet [00:33:51]).
      
      ## Report template
      
      State: engine and preset, view transform and look, lights with purpose/source/level/share, verdict list (empty or accepted with reason), the three images you looked at (render, value study, contact sheet), and what was not verified (for example: final Cycles quality not rendered, animation flicker not checked).
      
    • expert-notes.md 19.5 KB
      # Expert notes: lighting, color, rendering, compositing, composition
      
      Principles and judgment by expert, with the video id and `[hh:mm:ss]`. Full notes: `notes/lighting/<id> - <title>.md`. "Measured" = re-tested on 5.2.1 in this skill's tests (see procedures.md). [added] = not from the experts.
      
      ## Andy Goralczyk (Blender Studio art director) on film lighting, APgQKQgAdyU
      
      - **The job:** "make sculptures that are in motion look right" [00:20:18]. Images blast by, so everything must read instantly [00:01:16]. Appeal and dimensionality over naturalism; fog and depth "at the end of the day it's a distraction" [00:19:44].
      - **Gradients along the faces** matter more than the three-point metaphor [00:20:28]. Vocabulary: shadow area (colored by the environment/bounce), highlight (key), rim. A tiny shared language let two lighters do 227 shots [00:16:08].
      - **Value separation** character vs background in almost every shot, checked desaturated: "your brain just registers all the values and then all the colors next" [00:22:09] [00:22:42]. Keep the action in the area of biggest contrast.
      - **Vignette in the lighting**: light the center, mask the edges with falloff, the world feels staged [00:23:18].
      - **Restrict speculars** to the eyes and the few props that must read as metal, and keep those dull: tiny highlights cost noise and render time and twinkle in the background [00:24:23].
      - **Rim side:** day, on the key side; on the shadow side "it looks a little fake", like a bounce from nowhere [00:53:36]. Night: rim may move to the back to separate from the background [00:21:34]. Backlit: the rim is the main source, still fake a key into the face [00:21:34].
      - **Continuity** comes from the sun direction relative to the set, decided from a location breakdown and color script, with about 30 deg of leeway toward backlight or frontal [00:29:30]. Stylize left/right light in shot/reverse-shot [00:36:04].
      - **Color value library:** node groups of RGB values linked into every asset and light; per-shot deviations through a Hue/Saturation node after the library color [00:30:38] [00:31:43]. Limited palette: 2 to 3 greens, one ground brown [00:18:02].
      - **Eye highlights:** never in the lower half ("never do this unless you're told to do so, and even then protest") [00:35:30]; wrong placement makes a character cross-eyed [00:32:46]; a per-shot arrow cheat sheet aligns animation and lighting [00:33:51].
      - **Primary shots:** light the wide, propagate to coverage, "doing one shot lights five others" [00:37:12]. **Contact sheet** of all shots exposes outliers playback hides [00:38:59].
      - **Shot session:** link a template world (no shot starts from black) [00:48:59]; split world: camera sees a simple drop-off, lighting world blue from above and warm from below [00:49:35]; fill = disk area colored from the world, low; key = duplicate, brighter, small-ish; sun angle about 2 deg; shadow caster keeps the sun off the character [00:50:46] to [00:52:30]; darken the background a little [00:53:02].
      - **Cycles economy:** 3 bounces (5 at most for a city; 16 is "ridiculous"), no clamping, no caustics, Fast GI replace saved 10 to 30 %, adaptive sampling, OIDN; tests about 100 samples, finals 500 to 1,500 [00:45:05] to [00:47:54]. Bounces are faked with lights [00:46:47].
      - **Output:** 32-bit multilayer EXR with denoising data and Cryptomatte; comp to 16-bit EXR [00:41:12].
      - Demo numbers (fill 2, key 20) are one quick demo, not a ratio rule.
      
      ## Andrew Price (Blender Guru) on fundamentals, ENnEYoUpFfU
      
      - **Shadow is your friend** [00:03:41]; the many-lamp failure is the absence of shadow, not the count [00:02:36]. Frontal light reads as flash photography [00:05:13].
      - **Kill the world** first (strength 0) so every photon is yours [00:01:32]. **Lock the camera before lighting**: "lighting is view dependent" [00:05:13].
      - **Shadow-side lamp** dim enough that the side still reads as shadow but shows its detail; the textbook "fill at half the key" is "bogus", judge by eye [00:04:45]. "Every light needs to have a purpose", not key/fill/rim thinking [00:34:40]. So no key:fill ratio (3:1, 4:1) is a rule; `three_point(fill_ratio=0.25)` is a starting value [added] and the result is measured on the subject.
      - **Ground:** always a surface; near-black, rough, so a light object reads and the shadow side does not merge; darken a textured ground with RGB Curves [00:09:33] [00:30:54].
      - **Inverse square** (double the distance, a quarter of the light; measured 0.2501) as the attention tool: move a light close to what matters [00:13:27] [00:14:31]; for even priority, a distant high point lamp, not a flat-looking sun [00:16:42].
      - **Light size:** small = shadows, scratches, normal detail; large = form, hides pores [00:21:27] [00:22:34]; for faces and characters, large sources [00:19:17] [00:22:34]; hard light signals sun or streetlamp and aggression [00:23:06]; too large flattens to "color values" [00:23:39]. In code: the rig key is as big as the subject radius [added], and `light_report(face=head)` flags a key smaller than the head [added threshold]; a 2 to 5 cm point key on a face is the wrong tool unless the look is toon or deliberately harsh.
      - **Kelvin** for natural sources (candle about 1,500 K, incandescent 2,000 to 2,500 K, daylight about 6,500 K), RGB for artificial or sci-fi [00:24:11] [00:24:58]. Different colors on different sides separate planes [00:26:35].
      - **Workflow:** point lamps first, spots later to carve [00:37:25]; never adjust a lamp with others visible [00:35:13]; A/B every change; try a radically different setup to escape micro-tweaking [00:40:10]; ask a fresh eye [00:42:21]. 80 mm for boxy products, 50 mm distorts [00:32:32].
      
      ## Andrew Price on EEVEE, -gW6vk_OuNQ
      
      - EEVEE is "technologies stacked on each other's shoulders wearing a trench coat" [00:03:09]; screen space only knows what is in front of the camera [00:10:50].
      - **Ray tracing is off by default** [00:09:07] (measured: factory `use_raytracing` False). Pure ray tracing is dark and muddy on rough surfaces: Fast GI is required [00:22:09]; 0.5 max roughness splits them [00:25:26]; Fast GI steps 16 minimum [00:28:23] (measured on 5.2.1: 8 vs 16 almost identical after the overhaul); most bounce from a baked volume probe [00:28:57].
      - **Leaks:** thick walls in any engine [00:02:36]; shadow steps 10 to 16 [00:04:47]; very soft lights still leak. **Penumbra noise:** shadow rays 2 instead of more samples [00:08:11].
      - **World leak:** without a volume probe, world light fills a sealed room [00:37:14]. He places the outermost grid points exactly at the inner wall ("the last point needs to be the exact position of the wall") [00:38:22] and warns that a high search distance lets points jump through walls [00:41:10]. Measured on 5.2.1 (test_13): 5.2.1 puts the samples one spacing inside the box faces (local (i + 1) / (N + 1) x 2 - 1, from the shader in the binary), and the leak follows where the outer samples sit: 0.44 m inside the walls 0.223, 0.2 m 0.054, at the walls 0.0022, half a 0.2 m wall deep 0.0007, 0.1 m past the outer face 0.0091 (no probe 0.26). **Andrew's rule holds.** The skill's first version sized only the box, assumed points on its faces, and wrote that the rule was not reproduced; its "wall + half a cell" box had in fact put the samples within 1.3 cm of the walls. `volume_probe_for_room` now sizes the box from the sample positions.
      - **Probes:** plane probes spawn under the floor [00:15:47]; sphere probe clip start: too low self-reflects, too high opens a portal [00:55:30]; probes cannot see other probes [00:18:48]; fog and glass block volume bakes, hide them [01:04:39]; re-bake after material changes [00:49:02]; add probes only where artifacts show [01:12:42]; surfel resolution 100 [00:43:51].
      - **HDRI:** the sun extraction threshold applies after the Background strength [00:51:15] (measured); he sets threshold 0 and adds his own sun lamp [00:51:50].
      - **Glass:** thin glass cannot show what is behind via refraction; fake glass = Transparent + Glossy by Layer Weight Facing [00:59:15]; glass close-ups belong in Cycles [00:56:36]. 5.2 adds Principled Thin Wall (g4OXlrxqIx0 [00:17:59]).
      - **Performance:** removing constant texture maps took 11 s to 4 s per frame [01:11:04]; final ray tracing 1:1, GI 1:2; 1/16 flickers [01:15:34]. Temporal accumulation smears textures [00:48:28].
      
      ## Gleb Alexandrov (Creative Shrimp) on EEVEE portraits, 6hPZ0ckL5I0
      
      - One well-placed light is often enough [00:00:56]. Any angle beats frontal: "flattening, not flattering" [00:02:56] [00:14:48].
      - **Custom distance** high so lights behave physically [00:01:26] (measured: automatic distance loses 8 % on a low-power area light). **Per-light jitter** for microshadows under nose, chin, eyelids, "absolutely critical" [00:05:55] (measured: head p5 luma 0.0060 to 0.0044).
      - Skin: Specular IOR Level above 0, smaller SSS radius, low Material Output Thickness [00:04:24] [00:04:54].
      - HDRI needs ray tracing on (occlusion, no leaks behind ears) [00:08:00]; split world with Is Camera Ray to darken the backdrop without darkening the face [00:08:34] (measured: works fully in 5.2.1 EEVEE); for HDRI stills: denoise off, Fast GI off for precise contact shadows, resolution 1:2 or 1:1 [00:10:47].
      - Cinematic layer: harsh backlight sun as kicker motivated by the plate, duplicate with a large angle as wrap, bounce lights colored from the plate; stop before it "looks lit" [00:09:42] [00:17:06] [00:17:36] [00:19:40].
      - Eye light: tiny, far, light-linked to the eyes, diffuse down: "that tiny little glint? That's emotion" [00:20:35] (measured: linking honored in EEVEE and Cycles).
      - "Be generous with haze" [00:21:12]. View: ACES 2.0 or AgX High Contrast with a little exposure; Khronos PBR Neutral oversaturates skin [00:21:42].
      
      ## Lino Thomas (Egosoft art director) on color, Xqne7oRxSjM
      
      - Contrast in value, saturation **and** hue; when an image fails one is missing; "contrast of contrast" [00:01:06] [00:01:38]. Think in gradients of all three on every surface [00:02:04].
      - Screen color wheel is additive (red vs cyan) [00:02:58]. Vectorscope beats gut feeling [00:05:39].
      - **Balance:** one saturated focus color; its complement desaturated over a large area or saturated only in small dots; two full-saturation opposites "look like a child drawing" [00:07:40] [00:08:15]. Triadic: one saturated focus, two toned down [00:09:19].
      - Start from darkness, be bold with the sun; volume with anisotropy for a haze gradient; foreground high contrast, background midtones [00:16:07] [00:17:13] [00:17:45].
      - **Blockers:** hole-cut cube around the camera darkens the frame sides; shadow distracting shapes instead of painting them black; move a blocker away from a surface to widen the gradient [00:18:19] [00:19:25] [00:20:34].
      - **Fake second sun:** spot with a 1 deg cone, far and bright, over the hero; a blocker keeps the real sun off it; two real suns only brighten everything [00:22:24] [00:25:05]. Exclude faked lights from volume scatter [00:23:29].
      - Rims colored by eyedropping the rendered background, then raised in value [00:30:45]. Counter-hue dirt lands near neutral under colored light [00:33:09]. Sneak the foreground color into the background and back [00:43:31]. Explore hue rotations on the render, then eyedrop winners back into 3D [00:45:47].
      
      ## Jacob Holiday (VFX) and Nathan Vegdahl (color science), qt1GidFwVt0, SqJ_rtaPqg4
      
      - Name the color space and transform at every hop [00:09:49]. Data maps Non-Color [00:10:50]. The image color space dropdown is a transform, not a label [00:13:36].
      - View transform = working space to display; Standard clips, AgX/Filmic/Khronos compress [00:16:03]; a look is an artistic adjustment before the transform [00:16:36].
      - PNG bakes view, look and display (Follow Scene); EXR stays working-space linear [00:19:17]; intermediates wide and linear, finals display-referred [00:17:40]. Background footage needs View as Render [00:28:09]. Khronos PBR Neutral for brand RGB fidelity [00:45:24] (measured: a brand red or blue in a white furnace within 3.8 of 255 steps, AgX 27 to 42, Standard 33; a zero channel is lifted to about 24 by its toe; above the knee it compresses). AgX stays the scene transform.
      - **Exposure, not light energy, for global brightness** (digest attribution [00:16:36] [00:45:24]): measured, exposure +1 equals every emitter x2 exactly, while doubling only the lamps leaves the world and emissive surfaces behind and changes the ratios. 5.2 adds camera log spaces (V-Log, S-Log3, LogC4, Apple Log), so his Resolve pre-conversion is unneeded.
      - Vegdahl: the horseshoe is cone-stimulation ratios, not "all visible colors" [00:22:45]; equal-channel white vs perceptual white [00:33:19]; **the working space changes render results** [00:44:14] (measured: the operator converts existing colors; choose once).
      
      ## Andrew Price on composition, O8i7OKbWmRM
      
      - Pyramid: one focal element, then a structure, then balance [00:01:00]. The highest-contrast area becomes the focal point regardless of intent (bright window beats the couch) [00:04:00]. Two equal focal elements irritate [00:11:00].
      - Stack 3 to 5 cues on the one element: contrast, saturation, focus/DoF, face or figure, guiding lines, framing [00:05:30] to [00:09:30]. Never lead the eye off the page; place stoppers [00:08:00] [00:10:30].
      - Structures: thirds (character plus environment, dialogue), pyramid (heroic, low camera), symmetry (calm, power, reflections), full frame (single subject); "any structure is better than none" [00:12:30] [00:14:00] to [00:22:00]. Near a thirds point is enough [00:15:30].
      - Balance around the vertical center like a scale; figures carry weight even small and dark; shadows balance masses [00:22:30] [00:26:00] [00:28:30]. **Squint test** = contrast up plus blur, then a second pass for saturation, faces and figures [00:25:00].
      
      ## Robin Ruud, Pau Homs, Habib Gahbiche on passes and the 5.x compositor
      
      - Robin (vtdczoXVyvQ): studios split light and layers so late changes need no 3D re-render [00:32:55] (light groups: one render, key/fill/rim/window gains in comp); multilayer EXR, half float, DWAA [00:01:30]; back to beauty = per component (direct + indirect) x color, plus emission and environment; group defaults black for added inputs, white for multiplied [00:03:10] [00:04:52] (measured exact, 7.6e-7); "things that both go in front of and behind other things need holdouts", shadow/light casters are indirect only [00:17:14] (measured); render single layer after a fix [00:17:47]; "the image saved in linear should look awful" [00:19:33]; "if you don't know why you need this then you don't need it" [00:32:21].
      - Pau (kAWfjBKcgFc): "always, always comp your renders" [00:01:10]; minimum finish bloom + vignette + correction, not "2013's Instagram" [00:06:32]; order matters [00:07:04]; passes as masks (inverted AO, Cryptomatte, glow by pushing above 1) [00:08:59] [00:09:32]; AO multiply is for EEVEE, it double-counts in Cycles [00:08:27]; pixel-unit inputs through Relative To Pixel [00:11:54] (measured); viewport comp shows passes only with EEVEE [00:10:49]; render Cycles animation dry and comp from EXRs in another file [00:12:47].
      - Habib (hFzg41j68hg): the compositing tree is its own node-group data-block [00:20:28]; options are sockets; "if you think about vignette, you should have a vignette" (assets) [00:13:59].
      
      ## Wyatt Hall and the Singularity team on stylized looks, M4v_hfGF4EM, NJGyiWqNJoE
      
      - Wyatt: plan the final image in comp from day one, character and environment lit separately, down to headlights vs body lights [00:07:23] [00:07:57] (vanilla 5.2: Cycles light groups, `BL.light_groups` + `lightgroup_mix`, measured to match a re-render within noise; EEVEE has no light-group outputs, use light linking and view layers); lookbook with don'ts and a key visual [00:04:39]; terminator tint band, subtle ("if you squint it's there") [00:11:07]; soft low-res shadows "suggest depth as opposed to prove it", crisp ones for toon terminators [00:18:26] (measured: filter radius + coarse resolution limit double the in-between share; a big jittered key dithers a toon terminator); LOD by screen coverage not distance [00:21:09]; topology follows desired shadow shapes, normals from a static proxy via Data Transfer, never edited on the deforming mesh [00:28:36] [00:29:40]; AOV names must match exactly or nothing renders, silently [00:43:07]; rim and light wrap in comp when materials are library-linked [00:49:42]; never step animation globally after the fact [00:38:31].
      - Singularity (Blender Studio): additive glow keeps stacked strokes brightening, needed for HDR [00:24:07]; watercolor edge layers from distance to silhouette with chroma shift per layer [00:23:32]; sphere-mapped strokes read the same from any angle [00:22:59]; timing chosen on a painterly render test: local motion on twos, big travel on ones [00:42:47] [00:43:56]; scale without atmosphere: shrink the giant with distance, add swarms of small creatures [00:49:33]; "sometimes 95% is good enough" [00:07:22].
      
      ## Where experts disagree, and the deciding condition
      
      | Choice              | A                                        | B                                              | Decide by                                                                                         |
      | ------------------- | ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
      | Light vocabulary    | Named roles (Andy, Gleb)                 | Purposes only (Andrew)                         | Many shots or people: shared vocabulary; one still: purpose                                       |
      | Fill level          | Demo values (Andy)                       | "Half the key is bogus" (Andrew)               | Neither is a rule: measure on the subject (`light_contributions`, light-group shares, form ratio) |
      | Rim side            | Key side (Andy, day)                     | Opposite, motivated (Andy night, Gleb kicker)  | A visible or implied source behind on that side                                                   |
      | EEVEE Fast GI       | Required (Andrew, bounce interiors)      | Off for HDRI portraits (Gleb)                  | Interior bounce vs close-up HDRI character; re-test on 5.2                                        |
      | RT denoise          | Keep, temporal off (Andrew)              | Off (Gleb, stills)                             | Stills tolerate noise; animation needs denoise                                                    |
      | HDRI sun            | Threshold 0 + own sun (Andrew)           | Keep HDRI sun, soften (Gleb)                   | Need to art-direct sun height vs keep the plate's key                                             |
      | Where to grade      | Blender compositor (Lino, Pau)           | Resolve/Fusion from EXR (Jacob, Robin)         | Agent: Blender; keep EXR masters for a human colorist                                             |
      | View transform      | Khronos PBR Neutral (Jacob, brand color) | ACES 2.0 / AgX High Contrast (Gleb, portraits) | Product color fidelity vs photographic skin                                                       |
      | Volumes             | EEVEE excels (Andrew)                    | Cycles fine now (Lino)                         | Budget and animation length                                                                       |
      | Comp inside/outside | Blender (Pau, Habib)                     | External (Robin, Wyatt)                        | Headless agent: Blender; handoff to other departments: EXR                                        |
      | Structure           | Thirds (default)                         | Pyramid, symmetry, full frame                  | Character + environment: thirds; heroic: pyramid; calm/architecture: symmetry                     |
      
    • procedures.md 29.5 KB
      # Procedures (tested on Blender 5.2.1 LTS)
      
      Every procedure below ran headless with `blender --background --factory-startup --python-exit-code 1 --python <test>`. Tests live in `tests/code/blender-lighting-rendering/` (fixture: `_common.py`, a stylized character in a 4 x 3.5 m workshop with 0.2 m walls, a window and a hanging lamp). The module is `<skills>/scenario-blender-lighting-rendering/scripts/bx_light.py`; import it with:
      
      ```python
      import sys; sys.path.append("<skills>/scenario-blender-lighting-rendering/scripts"); import bx_light as BL
      ```
      
      It works in a live GUI session too (object mode). Tests: `test_01` to `test_13` (172 checks, all pass on 5.2.1). Raw bpy is given where it is short, so a session without the module can still do it.
      
      ---
      
      ## P1. Light units: set energies from a target level, not from watts
      
      Verified on 5.2.1: `test_01_units_placement.py`.
      
      A light's _level_ = the linear value a white Lambertian card facing the light at the target reaches. Measured constants (EEVEE and Cycles agree to 0.1 % with custom distance on):
      
      | Light                                         | Level at distance d                | `BL.energy_for`                 |
      | --------------------------------------------- | ---------------------------------- | ------------------------------- |
      | Point, spot (cone does not concentrate power) | P / (4 pi^2 d^2) = 0.02533 P / d^2 | `energy_for("POINT", level, d)` |
      | Area (small, `normalize` on, any shape)       | P / (pi^2 d^2) = 0.1013 P / d^2    | `energy_for("AREA", level, d)`  |
      | Sun                                           | S / pi = 0.3183 S                  | `energy_for("SUN", level)`      |
      
      - A source as large as its distance delivers 75 % of the nominal level (1 m area at 1 m: 0.752 in both engines); a point radius of d/2 about 80 %.
      - Inverse square verified: same power at double distance = 0.2501 of the level (Andrew Price [00:13:27]).
      - **EEVEE custom distance (Gleb [00:01:26]) is not cosmetic:** with the automatic distance a 5 W area light lands 8 % low (0.459 vs 0.4995); with `use_custom_distance = True, cutoff_distance = 100` EEVEE matches Cycles. `BL.eevee_softness(light, dist)` sets it on every rig light.
      
      ```python
      L = BL.new_light("Key", "AREA")                 # get-or-create in collection BX_Lights
      d = BL.place(L, head, 40, 35, 2.2, cam)         # returns the distance actually used
      L.data.energy = BL.energy_for("AREA", 1.0, d)   # white card at the head would read 1.0
      BL.eevee_softness(L, d)                          # custom distance + jitter if >= 3 deg apparent size
      ```
      
      Display mapping of linear values (emission ramp through each view, `test_01`):
      
      | linear              | 0.02  | 0.05  | 0.18  | 0.5   | 1.0   | 2.0   | 4.0   | 16    |
      | ------------------- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- |
      | Standard            | 0.153 | 0.247 | 0.463 | 0.737 | 1.0   | 1.0   | 1.0   | 1.0   |
      | AgX                 | 0.125 | 0.227 | 0.463 | 0.667 | 0.773 | 0.851 | 0.910 | 1.0   |
      | AgX - High Contrast | 0.063 | 0.165 | 0.463 | 0.733 | 0.851 | 0.933 | 0.988 | 1.0   |
      | AgX - Punchy        | 0.039 | 0.137 | 0.345 | 0.565 | 0.694 | 0.796 | 0.871 | 0.976 |
      | Khronos PBR Neutral | 0.031 | 0.133 | 0.412 | 0.710 | 0.937 | 0.980 | 0.992 | 1.0   |
      | ACES 2.0            | 0.039 | 0.133 | 0.349 | 0.565 | 0.706 | 0.820 | 0.898 | 0.976 |
      | Filmic              | 0.161 | 0.275 | 0.502 | 0.694 | 0.808 | 0.894 | 0.949 | 1.0   |
      
      So under AgX a key level of 1.0 on albedo 0.8 (linear 0.8) displays 0.74; and **clipping hides in the PNG** (linear 4 shows 0.91): read the EXR maximum.
      
      ## P2. Placing lights and the camera relative to subject and camera
      
      Verified: `test_01_units_placement.py`, `test_02_rigs.py`.
      
      Convention: azimuth 0 = light on the camera side (frontal), +90 = screen right of the subject, 180 = behind it; elevation above the target's horizontal plane. World (sun) azimuth is compass style, 0 = +Y, 90 = +X, identical to the Sky texture's `sun_rotation` (measured with an equirect probe).
      
      ```python
      BL.place(light, target, azimuth, elevation, distance, cam, avoid=subject_objs)  # avoid: stay out of walls
      BL.angles(light, target, cam)        # {'azimuth','elevation','distance','off_camera_axis'}
      BL.free_distance(target, direction, 5.0, ignore=subject_objs)   # ray cast, skips the subject
      BL.sun_azimuth(sun_obj)              # world azimuth/elevation toward the sun (continuity checks)
      BL.screen_xy(head, cam)              # frame position (x right, y up, 0..1)
      BL.aim_camera_at_frame_point(head, 1/3, 0.62, cam)   # turn the camera so the head sits on a third
      ```
      
      - Round trip place/angles within 0.2 deg over 32 az/el combinations.
      - Trap found while testing: `matrix_world` is stale right after setting location or rotation; every `bx_light` function that reads matrices calls `view_layer.update()` first. In your own code do the same.
      - Lights placed behind a subject in a small room end up inside walls (the first rim test contributed 0.0); `avoid=` pulls them in to 85 % of the free distance.
      
      ## P3. Three-point rig (and when to break it)
      
      Verified: `test_02_rigs.py`.
      
      ```python
      rig = BL.three_point(subject_objs, target=head, key_az=40, key_el=35, fill_ratio=0.25,
                           key_temp=None, fill_color=None, rim_side="key", link_rim=False)
      print(BL.light_report(target=head, face=head))   # flags: frontal key, key below the eye line, key smaller than the head
      ```
      
      Defaults: key 40 deg off the camera axis, 35 deg up, 3 subject radii away, source as big as the subject radius (form); fill on the other side, 10 deg up, twice the key size, 0.25 of the key level [added]; rim behind on the key side (Andy [00:53:36]). All lights get custom distance and, when soft, per-light jitter. Idempotent.
      Break it when: the scene has a motivation (use P4 or P5); a product needs one lamp for form plus a dim shadow-side lamp (Andrew [00:04:45]); a portrait works with one light (Gleb [00:00:56]).
      
      - **Fill level is not a ratio.** `fill_ratio=0.25` is only a starting value [added]: Andrew calls the textbook "fill at half the key" "bogus" [ENnEYoUpFfU 00:04:45]; lower the shadow-side lamp until that side still reads as shadow but shows its detail, and measure the result on the subject (`light_contributions`, clay `internal_ratio`), not lamp values (distance and size change irradiance).
      - **Face keys are large sources.** Andrew: large sources for faces so form reads and pores do not; small ones for grit, aggression or a sun/streetlamp cue [00:19:17] [00:22:34]. The rig's key is as big as the subject radius (0.86 m on the fixture); `light_report(face=head)` flags a key smaller than the head (a 4 cm point key on a 0.62 m head is flagged, test_12). Ignore the flag for a toon key.
      - **Light linking lives on the light OBJECT**: `light_obj.light_linking.receiver_collection = coll` (and `.blocker_collection`); `bpy.types.Light` has no `light_linking` (verified), so `light.data.light_linking` raises AttributeError. `BL.link_light` accepts a Light datablock with one user and resolves its object (`BL.light_object`), and refuses a shared one (test_12).
      
      ## P4. Motivated interior (window + practical), dusk or night
      
      Verified: `test_02_rigs.py`, `test_08_s7_hero.py`.
      
      ```python
      rig = BL.motivated_interior(subject_objs, window=(window_center, (w, h)), practical=bulb_location,
                                  target=head, key="practical", key_level=1.2, window_level=0.35,
                                  practical_cone=110, cheat_key=True, link_key=True, link_rim=True)
      ```
      
      What it does, and why:
      
      - Window: rectangle area light in the opening, sized to it, facing in, colored by the sky (Gleb: match colors to the environment).
      - Practical: downward spot (the shade) at 2700 K (Andrew: point first, spot to carve light off what does not matter).
      - **Cheated key** when the key source is behind the subject (|azimuth| > 100): an area light in front on the source's side, same color, light-linked to the character (Andy [00:21:34]: in backlit scenes add a faked key in the face). The practical keeps half the key level for the set.
      - Floor bounce tinted by the key; rim from the source that is most behind; a second rim when that source is within 15 deg of straight behind (both edges are backlit).
      - Every light is kept inside the room (`avoid`).
        Measured effect on the fixture (EEVEE preview, `test_04` variant B vs `test_08`): with no cheated key, no linking, a bare point practical and no probe, subject vs surround = -0.011 (merges, silhouette merge 41 %); with the defaults plus the probe, +0.123, key share on the character 65 %, key contribution on the background median 0.006.
      
      ## P5. Exterior and world tricks
      
      Verified: `test_01`, `test_02`, `test_07_world.py`.
      
      ```python
      rig = BL.outdoor_sun_sky(subject, target=head, sun_azimuth=210, sun_elevation=35,
                               azimuth_space="world", sun_level=2.0, sun_angle=2.0, sky_strength=0.25)
      # Sky texture MULTIPLE_SCATTERING, sun disc off, synced to the lamp; world.sun_threshold = 0
      caster = BL.shadow_caster(rig["sun"], subject, target=head)   # Andy: dark plane, invisible to camera/glossy
      fs = BL.fake_sun(subject, rig["sun"], target=head)            # Lino: 1-3 deg spot far away for the hero
      BL.split_world(camera_color=(0.02, 0.02, 0.03))               # camera sees a dark backdrop, lighting unchanged
      BL.world_gradient(top=(0.25, 0.4, 0.9), bottom=(0.35, 0.25, 0.12), strength=0.5)  # Andy's lighting world
      eye = BL.eye_light(eye_objs, cam, level=3.0)                  # diffuse 0, linked to the eyes, upper half
      hz = BL.haze(room_objs, density=0.05)                          # bounds display, excluded from probe bakes
      ```
      
      Measured: shadow caster took the sun on the subject from 0.018 to 0.0 and does not cut the Workbench mask; split world backdrop x0.025 with the subject kept at 0.99 in EEVEE and Cycles (Gleb said "at least partially" for EEVEE; fully in 5.2.1); gradient world blue/red 1.9 on top, 0.61 underneath; the light-linked eye light: 1.50 on the eyes, 0.0001 elsewhere (EEVEE), 1.57 / 0.0003 (Cycles).
      
      Raw split world:
      
      ```python
      nt = scene.world.node_tree; bg = nt.nodes["Background"]; out = nt.nodes["World Output"]
      cam_bg = nt.nodes.new("ShaderNodeBackground"); lp = nt.nodes.new("ShaderNodeLightPath")
      mix = nt.nodes.new("ShaderNodeMixShader")
      nt.links.new(mix.inputs["Fac"], lp.outputs["Is Camera Ray"])
      nt.links.new(mix.inputs[1], bg.outputs["Background"]); nt.links.new(mix.inputs[2], cam_bg.outputs["Background"])
      nt.links.new(out.inputs["Surface"], mix.outputs["Shader"])
      ```
      
      ## P6. Render presets
      
      Verified: `test_03_presets_color.py`.
      
      ```python
      BL.preset_eevee(quality="preview"|"final", interior=True, animation=False)
      BL.preset_cycles(quality="preview"|"final", budget="film"|"default", glass=False)   # picks Metal/OptiX/CUDA/HIP
      ```
      
      EEVEE raw: `sc.render.engine = 'BLENDER_EEVEE'`; `sc.eevee.use_raytracing = True` (factory False); `ro = sc.eevee.ray_tracing_options; ro.resolution_scale = '1'` (final) / `'2'`; `sc.eevee.fast_gi_step_count = 16`; `sc.eevee.fast_gi_resolution = '2'`; interiors `shadow_step_count = 12`; finals `shadow_ray_count = 2`; stills `ro.denoise_temporal = False`; samples 32 preview / 128 final [added].
      Cycles film raw (Andy): `max_bounces = diffuse_bounces = glossy_bounces = transmission_bounces = 3`, `caustics_reflective = caustics_refractive = False`, `sample_clamp_indirect = 0`, `use_fast_gi = True`, `fast_gi_method = 'REPLACE'`, `ao_bounces_render = 1`, adaptive sampling, OIDN; samples 128 preview / 1024 final, threshold 0.03 / 0.01 [added thresholds].
      Timings on the fixture (960x540, M5 Max): EEVEE preview 0.35 s, final 1.9 s; Cycles preview (Metal, first run includes kernel compile) 17 s.
      
      ## P7. Color management
      
      Verified: `test_03_presets_color.py`.
      
      ```python
      BL.color_setup(view="AgX", look="High Contrast", exposure=0.3)  # -> 'AgX - High Contrast'
      BL.working_space("Linear Rec.709")          # once, at project start; operator converts colors
      print(BL.color_audit())                     # Non-Color data maps, Standard view, Follow Scene, View as Render
      ```
      
      - `bpy.data.colorspace.working_space` is read-only; `bpy.ops.wm.set_working_color_space(working_space=..., convert_colors=True)` works headless; Base Color (0.10, 0.26, 0.26) became (0.16, 0.249, 0.257) in Rec.2020 and came back exactly.
      - `'Linear'` and `'Raw'` are rejected as image color spaces in 5.2 (use `'Linear Rec.709'`, `'Non-Color'`).
      - AgX looks: None, Punchy, Grayscale, Very High / High / Medium High / Base / Medium Low / Low / Very Low Contrast (all prefixed `'AgX - '`). ACES 1.3/2.0 have no looks.
      - **Exposure belongs in the view transform, not in scaled lights** (Jacob Holiday [qt1GidFwVt0 00:16:36], digest): `sc.view_settings.exposure = 1.0`. Measured (test_12, Cycles, fixed seed): exposure +1 gives exactly the frame of every emitter x2 (lamps, world, emissive bulb: PNG diff 0.0); doubling only the lamps does not (diff 0.0053: world and bulb forgotten, ratios changed). One reversible knob that keeps every ratio and plausible lamp values.
      - **View transform by job** (Jacob [00:45:24]; Gleb [6hPZ0ckL5I0 00:21:42]): AgX for scenes; `'Khronos PBR Neutral'` when a product or brand color must display as authored; ACES 2.0 or AgX High Contrast for portraits (Gleb: Khronos oversaturates skin). Measured in a white furnace (Principled plane, base color = the brand sRGB, test_12), max channel error in 8-bit steps:
      
      | Brand color (sRGB)                   | Standard | AgX  | Khronos PBR Neutral               |
      | ------------------------------------ | -------- | ---- | --------------------------------- |
      | red (200, 30, 45)                    | 33.1     | 41.8 | 3.8                               |
      | blue (30, 70, 160)                   | 33.1     | 26.8 | 3.8                               |
      | teal (0, 120, 120), a zero channel   | 54.4     | 54.8 | 23.7 (toe lifts the zero channel) |
      | orange (250, 120, 0), above the knee | 54.3     | 77.0 | 37.3 (compressed)                 |
      
      Khronos removes the dielectric specular lift that makes Standard look washed out; colors whose brightest channel exceeds about 0.76 linear (Khronos spec knee) are compressed, and a zero channel is lifted by its toe.
      
      ## P8. Subject masks
      
      Verified: `test_02`, `test_04_analysis.py`.
      
      ```python
      m = BL.mask_workbench(subject_objs)                 # any engine, visible silhouette, occluders count
      r = BL.render("/abs/out/shot")                      # shot.png + shot.exr (multilayer, all passes)
      m = BL.mask_cryptomatte(r["exr"], [o.name for o in subject_objs])   # needs use_pass_cryptomatte_object
      ```
      
      - Workbench vs Cryptomatte IoU 0.987 in EEVEE and in Cycles. EEVEE 5.2.1 writes Cryptomatte but **no Object Index pass**; use Cryptomatte for ID masks.
      - OIIO 3.1 ships with Blender. Trap: `ImageInput.read_image(subimage, miplevel, chbegin, chend, format)`: the first argument is the subimage; passing 0 reads part 0 whatever `seek_subimage` chose.
      - With compositing on, the main multilayer EXR holds `ViewLayer.*` passes plus `Composite.Combined`; `BL.load_rgba` picks the composite, `part="ViewLayer.Combined"` the raw render.
      
      ## P9. Render analysis and verdict
      
      Verified: `test_04_analysis.py` (synthetic images with known answers plus four calibrated variants).
      
      ```python
      rep = BL.analyse(r["png"], m, exr=r["exr"])   # dict, see docstring
      print(BL.verdict(rep))                        # list of problems; [] = pass
      ```
      
      Key fields: `subject.minus_ring` (subject median minus the median of a ring 4 % of the diagonal around it), `subject.edge_merge_pct` (outline pixels where inside/outside differ by < 0.05), `subject.internal_ratio` (p90/p10 of subject values: form), `subject.band` vs `ring_band`, `focus.peak_on_subject`/`concentration` (local-contrast peak), `squint` (16x9 weights), `vignette_ratio`, `value_range` (p2..p98), `clip_pct`/`crush_pct`, `linear_max`/`over1_pct`, `hue.clusters`, `thirds.distance`.
      
      Calibration (EEVEE preview, fixture):
      
      | Variant                                                | minus_ring       | edge merge % | internal ratio (clay) | verdict                      |
      | ------------------------------------------------------ | ---------------- | ------------ | --------------------- | ---------------------------- |
      | A flat: frontal key, bright ambient                    | +0.141           | 17.7         | 2.07 (1.34)           | flat light                   |
      | B motivated, no cheated key, no linking, world leaking | -0.011           | 40.8         |                       | separation, silhouette merge |
      | C designed (P4 + probe)                                | +0.116 to +0.123 | 31 to 32     | 11.7 (6.05)           | pass                         |
      | D = C with light walls                                 | +0.049           | 30.8         |                       | separation, same band        |
      
      Thresholds in `BL.THRESH` [added, set from this table]: minus_ring 0.10, edge merge 35 %, internal ratio 2.5 (use a clay render for a pure lighting measure), value range 0.30, clip 0.5 %, crush 25 %, vignette ratio 1.0. Separation alone does not catch flat light (A separates): that is what the form ratio is for.
      
      ## P10. Looking like a lighter: value study, solo lights, clay, contact sheets
      
      Verified: `test_04`, `test_08`.
      
      ```python
      BL.value_study(r["png"], "/abs/out/values.png", mask=m)   # [desaturated | 4 value groups | squint]
      res = BL.light_contributions(rig.values(), m, "/abs/out/solo")  # each light alone + world alone
      BL.clay(True); ...; BL.clay(False)                         # view_layer.material_override gray
      BL.contact_sheet(pngs, "/abs/out/sheet.png", cols=4)
      BL.sheet_stats(pngs)    # outlier = > 0.5 stop from the median (or > 2 sigma with 8+ shots)
      ```
      
      `light_contributions` returns per light the mean/median linear luminance on the subject, the background median and its share of the total. A z-score cannot exceed (n-1)/sqrt(n), so small sets use the median rule; on 7 coverage shots plus one +2 EV shot only the latter was flagged.
      
      ## P11. EEVEE specifics, measured
      
      Verified: `test_05_eevee_specifics.py`.
      
      1. **World leak (sealed-room test) and Andrew's wall rule.** Room with no window, world strength 2, no lights, 0.2 m walls. Andrew puts the outermost probe points exactly at the inner wall [-gW6vk_OuNQ 00:38:22] ("the last point needs to be the exact position of the wall"). Where 5.2.1 puts the points: N samples per axis at local (i + 1) / (N + 1) x 2 - 1 (shader `eevee_lightprobe_volume_grid_sample_position`, read from the GLSL embedded in the 5.2.1 binary), i.e. one spacing INSIDE each box face, never on it. Interior mean luma against where the outer samples sit (`test_13`, `test_05`):
      
      | Outer samples                                          | Leak           | Note                                                                  |
      | ------------------------------------------------------ | -------------- | --------------------------------------------------------------------- |
      | no probe                                               | 0.260          | Cycles: 0.0000                                                        |
      | 0.44 m inside the walls (box stops at the inner faces) | 0.223          | a box drawn "to the walls"                                            |
      | 0.2 to 0.25 m inside                                   | 0.054 to 0.066 |                                                                       |
      | at the inner wall faces (Andrew)                       | 0.0022         | default of `volume_probe_for_room`                                    |
      | half the wall deep (0.1 m into 0.2 m walls)            | 0.0007         | lowest measured                                                       |
      | 0.1 m past the outer wall face                         | 0.0091         | leaks again (Andrew [00:41:10]: points must not escape through walls) |
      
      History of this finding: the skill's first version measured only box sizes, assumed the points sat on the box faces, and concluded that Andrew's rule was not reproduced and that the box must "enclose the walls". With the real sample positions the same numbers agree with Andrew: its "wall + half a cell" box had put the outer samples within 1.3 cm of the inner walls. The default now sizes the box from the points:
      
      ```python
      pb = BL.volume_probe_for_room(room_inner_lo, room_inner_hi, cell=0.5, wall=0.2)   # inset=0: samples on the inner walls
      print(BL.probe_report(pb, room_inner_lo, room_inner_hi)["flags"])                # [] ; flags samples > 0.1 m inside or past the wall
      BL.bake_probes()   # bpy.ops.object.lightprobe_cache_bake(subset='ALL'); synchronous headless
      ```
      
      Raw sizing: `N = round(2 * inner_half / cell) + 1`, `half = inner_half * (N + 1) / (N - 1)` per axis, `probe.scale = half`. Keep walls thick and `escape_bias` small; re-bake after material changes; seal the windows to test. 2. **Per-light jitter** on a soft key: head 5th-percentile luma 0.0060 without, 0.0044 with (contact shadows deepen). `scene.eevee.use_shadow_jitter_viewport` is viewport only. 3. **Fast GI steps** 8 vs 16: frame mean 0.0398 vs 0.0400 on 5.2.1 (Andrew's "more steps, brighter" is marginal after the 5.2 overhaul; keep 16, it costs little). 4. **HDRI sun threshold applies after strength** (Andrew [00:51:15]): synthetic HDRI with a 2000 sun: at strength 1 threshold 10 vs 0 changes the image (2.66 to 4.19 mean); at strength 0.004 (sun 8 < 10) the two are identical. The extracted sun's cast shadow did not show in this top-down setup (not verified). 5. Factory facts: `use_raytracing` False; `gi_cubemap_resolution` '512'; Cycles `sample_clamp_indirect` 10, 12 bounces, caustics on.
      
      ## P12. Compositor finish (5.x node group)
      
      Verified: `test_06_compositor.py`.
      
      ```python
      nodes = BL.comp_finish(bloom=0.25, threshold=1.0, vignette=0.3, contrast=0.05, color_boost=0.05)
      tree, rl, out = BL.comp_tree()          # scene.compositing_node_group, Group Output
      fac = BL.matte(tree, ["EyeL", "EyeR"])  # Cryptomatte by name -> Factor socket
      px = BL.relative_px(tree, rl.outputs["Image"], 0.01)   # pixel-unit input as 1 % of width
      ```
      
      Raw chain: `tree = bpy.data.node_groups.new("Comp", "CompositorNodeTree"); scene.compositing_node_group = tree`; `CompositorNodeRLayers` > `CompositorNodeGlare` (`inputs["Type"].default_value = "Bloom"`, display names) > `CompositorNodeGroup` with `node_tree` = the appended `Vignette` asset (`<DATAFILES>/assets/nodes/compositing_nodes_essentials.blend`) > `Tune Image` asset > `NodeGroupOutput` (after `tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")`).
      Measured: vignette ratio 0.723 to 0.544; ring around the bulb 0.115 to 0.222 (bloom); Cryptomatte grade +1 stop = x1.98 inside, x1.00 outside; Kuwahara driven by Relative To Pixel differs less between 50 % and 25 % renders (0.0072) than a fixed 10 px size (0.0096).
      
      ## P13. Passes, back to beauty, view layers, EXR
      
      Verified: `test_06_compositor.py`, `test_10_view_layers.py`.
      
      ```python
      BL.enable_beauty_passes()                    # diffuse/glossy/transmission direct+indirect+color, emit, env
      tree, rl, out = BL.comp_tree()
      rebuilt = BL.back_to_beauty(tree, rl)        # mean abs diff to Image 7.6e-7 (Cycles, no denoise)
      BL.file_output(tree, "//exr/", {"rebuilt": rebuilt, "image": rl.outputs["Image"]}, depth="32")
      ```
      
      View layers (Robin Ruud): `bpy.ops.scene.view_layer_add(type='COPY')`; per layer `vl.layer_collection.children["FG"].indirect_only = True` (casts light/shadow onto this layer) or `.holdout = True` (in front of and behind); work layer `vl.use = False`; comp `CompositorNodeRLayers.layer = "BGL"` + `CompositorNodeAlphaOver` (inputs Background, Foreground). Re-assembly vs one-layer render: 0.0004 mean abs diff; excluding instead of indirect-only: 0.016 (the shadow is lost). One layer: `bpy.ops.render.render(layer="BGL", write_still=True)`.
      Output: `ims.media_type = 'MULTI_LAYER_IMAGE'; ims.color_depth = '16'; ims.exr_codec = 'DWAA'` for comp masters; finals PNG with `color_management = 'FOLLOW_SCENE'`.
      
      ## P14. S7 end to end (stylized character, workshop at dusk)
      
      Verified: `test_08_s7_hero.py` (outputs in `archive/tests/lighting_skill/skill_out/test_08_s7_hero/`).
      
      1. Composition first: nose vs head screen x gives the facing; `aim_camera_at_frame_point(head, 1/3 or 2/3, 0.62)` gives look room. Result (0.332, 0.62).
      2. Dusk world (0.03, 0.05, 0.13), volume probe with its outer samples on the inner walls.
      3. `motivated_interior` + `eye_light`; `light_report(key="BX_Key")` clean.
      4. EEVEE preview, AgX Medium High Contrast, bake, render, Cryptomatte mask, `analyse`/`verdict` clean first time; key share 63 % (65 % with the first probe sizing), world 3.5 %.
      5. Final EEVEE (1.5 s) + `comp_finish`: verdict clean, thirds distance 0.087, clip 0.02 %, crush 6.4 %, linear max 19.5 (the bulb).
      6. Cycles reference (film preset): EEVEE/Cycles median on the character 0.94, background 1.54 (EEVEE lifts the set; judge the set in Cycles if it matters).
      
      ## P15. Stylized / NPR
      
      Verified: `test_09_npr.py`.
      
      ```python
      tm = BL.toon_material("toon_skin", (0.8, 0.52, 0.38), band_color=(0.9, 0.25, 0.1))  # EEVEE only
      BL.register_aovs()                        # adds 'toon_mask' (VALUE) to every view layer
      BL.outline_hull(obj, thickness=0.015)     # Solidify flipped + culled dark material, shadow-transparent
      BL.freestyle(thickness=2.0)               # or Freestyle lines
      light.data.shadow_filter_radius = 8.0; light.data.shadow_maximum_resolution = 0.2   # soft "suggestive" shadows
      ```
      
      - The toon factor comes from the ramp's **Alpha** (0/1): a Color-to-float link read 0.88 for white in this setup, leaking shadow color into lit areas [observed].
      - Toon terminators need a **small, unjittered key**: a 0.75 m jittered area key dithered the terminator (AOV max 0.88); a 5 cm key gave a binary mask (94 % of pixels at 0 or 1 at 960 px).
      - Measured: toon head 82 % of pixels in two values vs 13 % for the smooth Principled head; terminator band present; AOV missing before `register_aovs` (renders nothing, silently); hull darkened bright-background ring pixels 0.309 to 0.165; soft settings doubled the in-between share of the toon mask (0.056 to 0.128).
      - The hull shell encloses the mesh and **shadows it** unless the outline material is transparent to shadow rays (Light Path Is Shadow Ray + `use_transparent_shadow`), which `outline_hull` does.
      - Other options (not tested here): Grease Pencil Line Art `bpy.ops.object.grease_pencil_add(type='LINEART_SCENE')` (verified to exist by the distiller); toon normals by Data Transfer from a proxy (see `notes/lighting/M4v_hfGF4EM...`); Kuwahara painterly comp (P12).
      
      ## P16. Light groups: rebalance key, fill, rim, window without re-rendering (Cycles)
      
      Verified: `test_11_light_groups.py`.
      
      Wyatt plans the final image in comp and splits anything he may grade independently into its own light group [M4v_hfGF4EM 00:07:57]; studios split light so late changes need no 3D re-render (Robin [vtdczoXVyvQ 00:32:55]). **Cycles only in 5.2.1**: the EEVEE Render Layers node exposes no `Combined_<group>` outputs (verified), so in EEVEE separate with light linking and view layers.
      
      ```python
      rep = BL.light_groups({"key": [rig["key"]], "window": [rig["window"]], "rim": [rig[k] for k in ("rim", "rim2") if k in rig],
                             "practical": [rig["practical"]]}, world="window")      # leftovers (bounce, emissive bulb) -> "rest"
      r = BL.render("/abs/out/base")                                               # multilayer EXR with Combined_<group>
      print(BL.lightgroup_check(r["exr"], mask=mask))                               # rel_error ~0: groups add up; share per group
      lin = BL.rebalance(r["exr"], {"key": 1.6, "window": 0.5, "rim": 2.0})         # numpy, no render: iterate with analyze()
      mix = BL.lightgroup_mix({"key": 1.6, "window": 0.5, "rim": 2.0}, exr=r["exr"])  # comp-only scene BX_Regrade
      BL.render("/abs/out/regrade", scene=mix["scene"])                             # re-grade from the EXR
      BL.set_gain(mix, "key", (1.2, 1.1, 1.0))                                      # RGB gain = recolor a light (Pau)
      mix2 = BL.lightgroup_mix({"key": 1.6, "window": 0.5})                        # live comp on the Render Layers
      BL.comp_finish(bloom=0.0, vignette=0.3, source=mix2["socket"])                # finish continues the light-mix tree
      ```
      
      Raw: `vl.lightgroups.add(name="key")`; `light_obj.lightgroup = "key"` (an Object property, like `light_linking`; Light datablocks have neither); `world.lightgroup = "window"`; emissive meshes need `obj.lightgroup` too; the Render Layers node (and the EXR, part `ViewLayer.Combined_key`) gets one output per group; multiply each by a gain (`ShaderNodeMix` MULTIPLY, color sockets 6/7) and add. Renaming a view-layer group renames the objects' assignments.
      Measured on the workshop (Cycles, 64 samples, denoise off because light-group passes are not denoised): groups sum to the beauty with rel error 0.0; frame shares key 0.28, window 0.38, rim 0.04, practical 0.08, rest 0.23, and on the character (mask) key 0.77, window 0.12 (the linked key does its job); the numpy rebalance with key 1.6, window 0.5, rim 2 matches a real re-render with the lights (and world) scaled by the same gains within 2.2 % (noise; the unbalanced frame differs by 35 %); the comp-only regrade equals the numpy result (max diff 2e-6; also with an RGB gain) and took 0.2 to 0.3 s against 0.7 to 2.8 s for this tiny 240x135 render, a cost that does not grow with samples or scene complexity.
      
      ## P17. Exposure, product color, light-linking location, face key size
      
      Verified: `test_12_exposure_color_keys.py`. The numbers are in P3 (face key, fill level, light linking) and P7 (exposure, Khronos PBR Neutral vs AgX).
      
      ```python
      BL.color_setup(view="AgX", look="Medium High Contrast", exposure=1.0)   # brightness here, never by scaling lamps
      BL.color_setup(view="Khronos PBR Neutral")                               # product or brand color shots
      BL.link_light(key_obj, character_collection)                              # OBJECT; a single-user Light datablock is resolved
      print(BL.light_report(target=head, face=head)["flags"])                  # "small source for a face" when key < head
      ```
      
    • sources.md 15.3 KB
      # Sources
      
      Fifteen videos, distilled into `notes/lighting/` (one note each) and two digests (`_digest_lighting_color.md`, `_digest_render_composite_composition.md`). Timestamps are `[hh:mm:ss]` into the video. No frame sheets exist for these videos: everything comes from the audio. Expert claims marked "measured" in this skill were re-tested on Blender 5.2.1 (`tests/code/blender-lighting-rendering/`).
      
      ## Lighting design and color
      
      | Video                                                                                                         | Expert, credential                                                                    | Best for                                                                                                                                                                                                             | Best timestamps                                                                                                                                                                                                                                                                                                                                                                                                                                          |
      | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | [Lighting in Animated Film Productions](https://www.youtube.com/watch?v=APgQKQgAdyU) (BCON22, 54:49)          | Andy Goralczyk, Blender Studio art director, lighter and compositor on 13 open movies | Film lighting language (shadow/bounce, highlight, rim), value separation, rim side, continuity from sun direction, color value library, contact sheets, eye highlights, Cycles film economy, live shot-lighting demo | [00:20:28] gradients; [00:22:09] value separation desaturated; [00:23:18] vignette in lighting; [00:24:23] restrict speculars; [00:29:30] sun direction, 30 deg leeway; [00:30:38] color library; [00:34:56] eye highlights; [00:36:38] primary shots; [00:38:59] contact sheet; [00:46:13] 3 bounces; [00:47:19] no clamp, Fast GI; [00:49:35] split world; [00:52:30] shadow caster; [00:53:36] rim side                                               |
      | [Fundamentals of Lighting](https://www.youtube.com/watch?v=ENnEYoUpFfU) (2026, 43:25)                         | Andrew Price, Blender Guru, Poliigon founder                                          | Product/studio lighting from black: shadow for form, lock camera first, dark ground, inverse square as attention, light size = form vs detail, Kelvin vs RGB, point then spot, isolate lamps, A/B                    | [00:03:41] shadow is your friend; [00:04:45] fill ratio "bogus"; [00:05:13] lock camera; [00:09:33] dark ground; [00:13:27] inverse square; [00:14:31] falloff directs attention; [00:22:34] form vs detail; [00:32:32] 80 mm; [00:35:13] one lamp at a time; [00:38:31] spot to carve                                                                                                                                                                   |
      | [How to use EEVEE: full course](https://www.youtube.com/watch?v=-gW6vk_OuNQ) (2025, 1:16:54)                  | Andrew Price, with input from EEVEE lead Clement Foucault                             | EEVEE Next troubleshooting: leaks, shadow steps/rays, ray tracing off by default, screen-space artifacts, probes (plane, sphere, volume), world leak, HDRI sun threshold, glass, volumes, render-time pruning        | [00:04:47] shadow steps; [00:08:11] shadow rays; [00:09:07] ray tracing on; [00:10:50] screen space; [00:22:09] Fast GI required; [00:28:23] Fast GI steps 16; [00:37:14] world leak; [00:38:22] probe points at the wall; [00:43:51] surfel 100; [00:51:15] sun threshold after strength; [00:55:30] sphere probe clip start; [00:59:15] fake glass; [01:04:39] hide fog/glass before bake; [01:11:04] texture pruning; [01:15:34] final RT resolutions |
      | [EEVEE Character Lighting: One Light to Cinematic](https://www.youtube.com/watch?v=6hPZ0ckL5I0) (2026, 24:21) | Gleb Alexandrov, Creative Shrimp, lighting educator                                   | Portrait lighting in 4 levels: custom distance, per-light jitter microshadows, SSS, split world, double HDRI, kicker + wrap, eye light via light linking, haze, view transform choice                                | [00:01:26] custom distance; [00:01:56] jitter; [00:02:56] never frontal; [00:04:24] skin SSS; [00:08:00] ray tracing with HDRI; [00:08:34] split world; [00:10:47] HDRI render settings; [00:17:06] kicker + wrap; [00:17:36] match colors to environment; [00:20:35] eye light linking; [00:21:12] haze; [00:21:42] ACES 2.0 / AgX High Contrast                                                                                                        |
      | [One Hour of Colors](https://www.youtube.com/watch?v=Xqne7oRxSjM) (BCON24, 49:10)                             | Lino Thomas, art director at Egosoft (X series)                                       | Contrast in value, saturation and hue; harmony balance; vectorscope; blockers as vignette; fake second sun; rims eyedropped from the background; counter-hue dirt; hue exploration in comp                           | [00:01:06] three contrasts; [00:02:04] think in gradients; [00:07:40] complement balance; [00:18:19] blocker vignette; [00:22:24] fake sun; [00:30:45] eyedrop rim color; [00:33:09] counter-hue dirt; [00:38:31] Hue Correct; [00:45:47] hue exploration                                                                                                                                                                                                |
      | [Why Color Management Sucks](https://www.youtube.com/watch?v=qt1GidFwVt0) (BCON24, 50:36)                     | Jacob Holiday, VFX artist (InLight VFX)                                               | Color pipeline hops, Non-Color data maps, view transform vs look, EXR linear vs PNG baked, View as Render, intermediate vs final exports                                                                             | [00:10:50] Non-Color; [00:16:03] Standard clips; [00:19:17] PNG vs EXR; [00:28:09] View as Render; [00:45:24] Khronos PBR Neutral for brand colors                                                                                                                                                                                                                                                                                                       |
      | [Not Color: Color Science](https://www.youtube.com/watch?v=SqJ_rtaPqg4) (BCON26, 48:39)                       | Nathan Vegdahl, color-science specialist (credited by Jacob Holiday)                  | Correct color vocabulary, white points, camera spaces, working space changes results                                                                                                                                 | [00:22:45] what the horseshoe is; [00:33:19] two white points; [00:44:14] the space changes results                                                                                                                                                                                                                                                                                                                                                      |
      
      ## Rendering, compositing, composition, stylized
      
      | Video                                                                                                              | Expert, credential                                                                                                | Best for                                                                                                                                              | Best timestamps                                                                                                                                                                                                                                                                                        |
      | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | [Understanding Composition](https://www.youtube.com/watch?v=O8i7OKbWmRM) (2014, 30:27)                             | Andrew Price, Blender Guru                                                                                        | One focal element, cue stacking, structures (thirds, pyramid, symmetry, full frame), balance, squint test, stoppers                                   | [00:04:00] contrast steals focus; [00:05:30] cue stacking; [00:08:00] never lead off the page; [00:14:00] thirds; [00:22:30] balance; [00:25:00] squint test; [00:26:00] figures carry weight. Transcript note: the `transcripts/O8i7OKbWmRM.txt` file is truncated; the note used the full auto track |
      | [Render Like a Professional: Passes, View Layers, ACES](https://www.youtube.com/watch?v=vtdczoXVyvQ) (2024, 34:21) | Robin Ruud, 3D artist and educator                                                                                | Back-to-beauty rebuild, holdout vs indirect only, multilayer EXR half DWAA, linear/ACEScg output                                                      | [00:01:30] EXR settings; [00:03:10] rebuild; [00:04:52] neutral defaults; [00:12:44] indirect only; [00:17:14] holdout rule; [00:19:33] linear should look awful                                                                                                                                       |
      | [Start Compositing in Blender 5.0](https://www.youtube.com/watch?v=kAWfjBKcgFc) (2025, 19:12)                      | Pau Homs, lighting artist in animation                                                                            | 5.0 compositor in practice: minimum finish, passes as masks, Cryptomatte, viewport comp, pixel-unit trap, render dry                                  | [00:06:32] bloom + vignette + correction; [00:08:59] AO mask; [00:09:32] Cryptomatte glow; [00:10:49] passes in viewport only with EEVEE; [00:11:54] Relative To Pixel; [00:12:47] render dry                                                                                                          |
      | [Compositing 5.0: Powerful yet Simple](https://www.youtube.com/watch?v=hFzg41j68hg) (BCON25, 20:48)                | Habib Gahbiche, Blender compositor developer                                                                      | Why the 5.0 compositor is a node group, options as sockets, assets                                                                                    | [00:11:16] New button; [00:12:20] File Output auto-link; [00:13:59] think in effects; [00:20:28] node group data-block                                                                                                                                                                                 |
      | [Production Lessons of Singularity](https://www.youtube.com/watch?v=NJGyiWqNJoE) (BCON26, 52:39)                   | Blender Studio team (Andy Goralczyk, Vivien Lulkowski, Julien Kaspar, Simon Thommes, Demeter Dzadik, Rik Schutte) | Painterly look, additive glow, watercolor edge layers, stepped motion on a painterly render, scale without atmosphere, planning                       | [00:22:59] sphere mapping; [00:23:32] watercolor edges; [00:24:07] additive glow; [00:42:47] twos vs ones; [00:49:33] scale cues                                                                                                                                                                       |
      | [Making Stylized Visuals: Anime and Short Films](https://www.youtube.com/watch?v=M4v_hfGF4EM) (BCON24, 51:03)      | Wyatt Hall, director, Winter Stellar Studios                                                                      | Anime NPR: separate character/environment light, terminator tint, soft vs crisp shadows, toon normals by Data Transfer, AOVs, comp rim and light wrap | [00:07:23] plan the final image in comp; [00:11:07] terminator tint; [00:18:26] soft shadows suggest depth; [00:21:09] screen-coverage LOD; [00:29:40] normals from a proxy; [00:43:07] AOV names; [00:48:35] light wrap in comp                                                                       |
      | [What's New in Blender 5.0](https://www.youtube.com/watch?v=4wEqD-jK0DU) (official, 53:38)                         | Jonathan Lampel (CG Cookie) and guests                                                                            | 5.0 color (HDR, ACES, working space), compositor node group and assets, Cycles changes                                                                | [00:28:44] working space choice; [00:29:50] perceptual picker side; [00:35:16] compositor data-block; [00:37:59] Convolve                                                                                                                                                                              |
      | [What's New in Blender 5.2 LTS](https://www.youtube.com/watch?v=g4OXlrxqIx0) (official, 36:57)                     | Jonathan Lampel, Wayne Dixon and guests                                                                           | 5.2 target features: Thin Wall, camera log spaces, save-output toggle, AOV dropdown, EEVEE ray-tracing overhaul, GPU compositor default               | [00:17:59] Thin Wall; [00:19:05] EEVEE changes; [00:21:27] GPU compositor; [00:22:33] Blank Image; [00:25:50] viewport comp passes need EEVEE                                                                                                                                                          |
      
      ## Which source for which question
      
      - "How should this be lit?" Andy (film, characters), Andrew fundamentals (products, single objects), Gleb (portraits in EEVEE).
      - "Why does EEVEE look wrong?" Andrew's EEVEE course, then the measured notes in this skill (world leak, custom distance).
      - "Is the palette working?" Lino. "Is the color pipeline right?" Jacob, Vegdahl for vocabulary.
      - "Is the frame composed?" Andrew's composition talk.
      - "How do I set up passes and comp?" Robin (passes, layers), Pau (5.0 practice), Habib (why).
      - "Stylized look?" Wyatt (anime NPR), Singularity team (painterly).
      
  • scripts
    • bx_light.py 100.5 KB
      """
      bx_light: lighting rigs, render presets, color management, 5.x compositor and render
      analysis for Blender 5.2 (tested on 5.2.1 LTS, headless; works in a live session too).
      
      What lighters do by eye (place lamps around a subject, squint at the frame, check the
      image desaturated, flip A/B, look at a contact sheet) done from Python with numbers.
      
        import sys; sys.path.append("<skill>/scripts"); import bx_light as BL
        BL.aim_camera_at_frame_point(head, 1/3, 0.62)              # compose first, then light
        pb = BL.volume_probe_for_room(inner_lo, inner_hi); BL.bake_probes()   # EEVEE interiors: samples on the walls
        BL.probe_report(pb, inner_lo, inner_hi)                    # outer-sample gaps to the walls, flags
        rig = BL.three_point(subject_objs, target=head)            # key/fill/rim around subject vs camera
        rig = BL.motivated_interior(subject_objs, window=(loc, (w, h)), practical=lamp_loc, target=head)
        rig = BL.outdoor_sun_sky(subject_objs, sun_azimuth=210, sun_elevation=35, azimuth_space="world")
        BL.eye_light(eyes); BL.shadow_caster(sun, subject); BL.split_world(); BL.haze(room_objs)
        BL.light_report(target=head, face=head)                     # az/el vs camera, size, level, flags
        BL.preset_eevee(quality="preview"); BL.color_setup(view="AgX", look="High Contrast")
        r = BL.render("/abs/out/shot")                              # shot.png (display) + shot.exr (linear)
        mask = BL.mask_cryptomatte(r["exr"], names)                 # or BL.mask_workbench(subject_objs)
        rep = BL.analyse(r["png"], mask, exr=r["exr"]); print(BL.verdict(rep))
        BL.value_study(r["png"], "/abs/out/shot_values.png", mask=mask)   # open it with the image reader
        BL.light_contributions(rig.values(), mask, "/abs/out/solo")  # per-light share on the subject
        BL.clay(True)                                               # grey override: judge form only
        BL.light_groups({"key": [k], "rim": [r]}, world="window")   # Cycles: one render, gains in comp
        mix = BL.lightgroup_mix({"key": 1.4}, exr=r["exr"]); BL.render(base2, scene=mix["scene"])
        BL.comp_finish(bloom=0.2, vignette=0.25)                    # Glare bloom > Vignette > Tune Image
        BL.toon_material(...); BL.register_aovs(); BL.outline_hull(obj); BL.freestyle()   # NPR
      
      Tests: tests/code/blender-lighting-rendering/test_01..13 (172 checks, all pass on 5.2.1).
      
      Units. A light "level" is the linear value a white Lambertian surface facing the light at
      the target would reach (albedo 1). Calibrated on 5.2.1 (EEVEE and Cycles agree):
      point/spot P/(4 pi^2 d^2), small area P/(pi^2 d^2), sun S/pi. Under AgX a level of 1.0 on
      albedo 0.8 (linear 0.8) displays 0.74; see references/procedures.md for the calibration table.
      
      Angles. azimuth 0 = light on the camera side (frontal), +90 = screen right of the subject,
      180 = straight behind it (backlight); elevation above the horizontal plane of the target.
      World azimuth (suns) is compass style: 0 = +Y, 90 = +X, same as the Sky texture's sun_rotation.
      Images from the analysis functions are arrays with row 0 at the TOP.
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import json
      import math
      import os
      import struct
      import time
      
      import bpy
      import numpy as np
      from mathutils import Vector
      
      try:
          import OpenImageIO as oiio  # bundled with Blender 5.x
      except ImportError:  # pragma: no cover
          oiio = None
      
      # white-card calibration (linear value of albedo-1 diffuse facing the light), 5.2.1
      K_WHITE = {"POINT": 1.0 / (4 * math.pi ** 2), "SPOT": 1.0 / (4 * math.pi ** 2),
                 "AREA": 1.0 / math.pi ** 2, "SUN": 1.0 / math.pi}
      LUMA = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
      LIGHT_COLL = "BX_Lights"
      ESSENTIALS = "assets/nodes/compositing_nodes_essentials.blend"
      DATA_INPUTS = {"Normal", "Roughness", "Metallic", "Height", "Displacement", "Strength",
                     "Alpha", "Specular IOR Level", "Coat Weight", "Coat Roughness", "Transmission Weight",
                     "Subsurface Weight", "Anisotropic", "Sheen Weight", "IOR"}
      COLOR_INPUTS = {"Base Color", "Emission Color", "Subsurface Radius", "Specular Tint", "Coat Tint",
                      "Sheen Tint", "Color"}
      
      
      # ======================================================================== geometry
      def _scene(scene=None):
          return scene or bpy.context.scene
      
      
      def _sync():
          """matrix_world is stale right after setting location/rotation until the view layer
          updates (5.x): call before reading world matrices of objects just placed."""
          try:
              bpy.context.view_layer.update()
          except Exception:
              pass
      
      
      def _objs(x):
          if x is None:
              return []
          if isinstance(x, bpy.types.Collection):
              return [o for o in x.all_objects if o.type in {"MESH", "CURVES", "CURVE", "SURFACE", "FONT"}]
          if isinstance(x, bpy.types.Object):
              return [x]
          out = []
          for o in x:
              out += _objs(o)
          return out
      
      
      def bounds(objs):
          """World bbox of evaluated objects -> (center, radius, lo, hi); radius = half diagonal."""
          objs = _objs(objs)
          dg = bpy.context.evaluated_depsgraph_get()
          pts = []
          for o in objs:
              eo = o.evaluated_get(dg)
              pts += [eo.matrix_world @ Vector(c) for c in eo.bound_box]
          if not pts:
              raise ValueError("bounds: no geometry")
          lo = Vector([min(p[i] for p in pts) for i in range(3)])
          hi = Vector([max(p[i] for p in pts) for i in range(3)])
          return (lo + hi) / 2, (hi - lo).length / 2, lo, hi
      
      
      def _point(t):
          if isinstance(t, Vector):
              return t.copy()
          if isinstance(t, (tuple, list)) and len(t) == 3 and all(isinstance(v, (int, float)) for v in t):
              return Vector(t)
          return bounds(t)[0]
      
      
      def aim(obj, point):
          """Point an object's -Z (lights, cameras) at a world point."""
          obj.rotation_mode = "XYZ"
          obj.rotation_euler = (Vector(point) - obj.location).to_track_quat("-Z", "Y").to_euler()
      
      
      def _cam_axes(cam, target):
          """Horizontal unit vectors: toward the camera from the target, and screen-right."""
          _sync()
          to_cam = cam.matrix_world.translation - target
          h = Vector((to_cam.x, to_cam.y, 0.0))
          if h.length < 1e-6:                      # camera straight above: use its up vector
              up = cam.matrix_world.to_3x3() @ Vector((0, 1, 0))
              h = -Vector((up.x, up.y, 0))
          h.normalize()
          right = cam.matrix_world.to_3x3() @ Vector((1, 0, 0))
          right = Vector((right.x, right.y, 0.0))
          right -= h * right.dot(h)
          if right.length < 1e-6:
              right = Vector((0, 0, 1)).cross(h)
          right.normalize()
          return h, right
      
      
      def direction(azimuth, elevation, target, cam=None, space="camera"):
          """Unit vector from the target toward a light at azimuth/elevation (degrees)."""
          az, el = math.radians(azimuth), math.radians(elevation)
          if space == "world":
              return Vector((math.sin(az) * math.cos(el), math.cos(az) * math.cos(el), math.sin(el)))
          cam = cam or bpy.context.scene.camera
          h, right = _cam_axes(cam, target)
          d = math.cos(az) * h + math.sin(az) * right
          return (math.cos(el) * d + math.sin(el) * Vector((0, 0, 1))).normalized()
      
      
      def free_distance(target, dirv, distance, ignore=(), margin=0.85, scene=None):
          """Distance along dirv from the target to the first occluder that is not in `ignore`
          (the subject itself, lights), times `margin`; `distance` if the path is clear. Keeps rig
          lights out of walls and furniture [added]."""
          sc = _scene(scene)
          dg = bpy.context.evaluated_depsgraph_get()
          names = {o.name for o in _objs(ignore)}
          origin, travelled = Vector(target), 0.0
          for _ in range(16):
              hit, loc, _, _, ob, _ = sc.ray_cast(dg, origin, dirv, distance=distance - travelled)
              if not hit:
                  return distance
              step = (loc - origin).length
              if ob is not None and (ob.name in names or ob.type == "LIGHT" or not ob.visible_shadow):
                  travelled += step + 1e-3
                  origin = loc + dirv * 1e-3
                  if travelled >= distance:
                      return distance
                  continue
              return max(0.05 * distance, (travelled + step) * margin)
          return distance
      
      
      def place(obj, target, azimuth, elevation, distance, cam=None, space="camera", avoid=None):
          """Put a light (or any object) on a sphere around the target and aim it there. With
          avoid=<subject objects>, the distance shrinks so the light stays in free space.
          Returns the distance actually used."""
          target = _point(target)
          d = direction(azimuth, elevation, target, cam, space)
          if avoid is not None:
              distance = free_distance(target, d, distance, ignore=avoid)
          obj.location = target + d * distance
          aim(obj, target)
          return distance
      
      
      def angles(obj, target, cam=None):
          """Azimuth/elevation of an object around the target, in the camera convention above,
          plus the angle between the light and the camera as seen from the target (0 = frontal)."""
          _sync()
          cam = cam or bpy.context.scene.camera
          target = _point(target)
          v = obj.matrix_world.translation - target
          dist = v.length
          h, right = _cam_axes(cam, target)
          el = math.degrees(math.asin(max(-1.0, min(1.0, v.z / max(dist, 1e-9)))))
          az = math.degrees(math.atan2(v.dot(right), v.dot(h)))
          to_cam = (cam.matrix_world.translation - target).normalized()
          off_axis = math.degrees(v.normalized().angle(to_cam))
          return {"azimuth": round(az, 1), "elevation": round(el, 1), "distance": round(dist, 3),
                  "off_camera_axis": round(off_axis, 1)}
      
      
      def sun_azimuth(sun_obj):
          """World compass azimuth/elevation (degrees) of the direction TOWARD a sun lamp's source."""
          _sync()
          z = (sun_obj.matrix_world.to_3x3() @ Vector((0, 0, 1))).normalized()
          return {"azimuth": round(math.degrees(math.atan2(z.x, z.y)) % 360, 1),
                  "elevation": round(math.degrees(math.asin(max(-1, min(1, z.z)))), 1)}
      
      
      def screen_xy(point, cam=None, scene=None):
          """Normalized frame position of a world point (x right, y up, 0..1) and depth."""
          from bpy_extras.object_utils import world_to_camera_view
          _sync()
          sc = _scene(scene)
          v = world_to_camera_view(sc, cam or sc.camera, _point(point))
          return v.x, v.y, v.z
      
      
      def aim_camera_at_frame_point(target, x=1 / 3, y=0.6, cam=None, scene=None, iters=6):
          """Turn the camera (position kept, no roll) so `target` lands at frame point (x, y):
          the composition fix for a centered subject (thirds: x 1/3 or 2/3; Andrew: near, not
          exact). Returns the final (x, y)."""
          sc = _scene(scene)
          cam = cam or sc.camera
          tgt = _point(target)
          cam.rotation_mode = "XYZ"
          for _ in range(iters):
              cx, cy, _ = screen_xy(tgt, cam, sc)
              ex, ey = x - cx, y - cy
              if abs(ex) < 0.002 and abs(ey) < 0.002:
                  break
              fr = cam.data.view_frame(scene=sc)
              half_w = max(abs(v.x) for v in fr) / abs(fr[0].z)
              half_h = max(abs(v.y) for v in fr) / abs(fr[0].z)
              cam.rotation_euler.z += math.atan(2 * ex * half_w)       # yaw: move subject right = turn left
              cam.rotation_euler.x -= math.atan(2 * ey * half_h)       # pitch
          cx, cy, _ = screen_xy(tgt, cam, sc)
          return round(cx, 3), round(cy, 3)
      
      
      # ======================================================================== lights
      def energy_for(ltype, level, distance=1.0):
          """Power (W) or sun strength giving `level` on a white card facing the light."""
          if ltype == "SUN":
              return level / K_WHITE["SUN"]
          return level * distance ** 2 / K_WHITE[ltype]
      
      
      def level_of(light_obj, point):
          """Inverse of energy_for: nominal level at a point (ignores angle, shadowing, cone)."""
          _sync()
          L = light_obj.data
          if L.type == "SUN":
              return L.energy * K_WHITE["SUN"]
          d = (light_obj.matrix_world.translation - _point(point)).length
          return L.energy * K_WHITE[L.type] / max(d, 1e-6) ** 2
      
      
      def _collection(name, scene=None):
          sc = _scene(scene)
          c = bpy.data.collections.get(name)
          if c is None:
              c = bpy.data.collections.new(name)
          if c.name not in sc.collection.children and c not in sc.collection.children_recursive:
              sc.collection.children.link(c)
          return c
      
      
      def new_light(name, ltype="AREA", scene=None):
          """Get-or-create a light object by name inside the BX_Lights collection."""
          ob = bpy.data.objects.get(name)
          if ob is not None and ob.type == "LIGHT":
              if ob.data.type != ltype:
                  ob.data.type = ltype
              ob.constraints.clear()
          else:
              ob = bpy.data.objects.new(name, bpy.data.lights.new(name, ltype))
          coll = _collection(LIGHT_COLL, scene)
          if ob.name not in coll.objects:
              coll.objects.link(ob)
          ob.hide_render = False
          return ob
      
      
      def set_color(light, temperature=None, color=None):
          """Kelvin for natural sources, RGB for artificial or stylized ones (Andrew Price)."""
          L = light.data if isinstance(light, bpy.types.Object) else light
          if temperature:
              L.use_temperature = True
              L.temperature = temperature
              L.color = (1, 1, 1)
          else:
              L.use_temperature = False
          if color is not None:
              L.color = color[:3]
      
      
      def set_size(light, size, distance=None):
          """Source size in metres: area edge, point/spot radius; for a sun, `size` is the angle in
          degrees. Bigger reads form, smaller reads texture and detail (Andrew Price)."""
          L = light.data if isinstance(light, bpy.types.Object) else light
          if L.type == "AREA":
              L.size = size
              if L.shape in {"RECTANGLE", "ELLIPSE"}:
                  L.size_y = size
          elif L.type in {"POINT", "SPOT"}:
              L.shadow_soft_size = size
          else:
              L.angle = math.radians(size)
      
      
      def eevee_softness(light, distance, jitter_threshold_deg=3.0):
          """Gleb Alexandrov's EEVEE switches: custom (long) distance on every light; per-light
          shadow jitter on soft lights so contact microshadows appear. Threshold [added]."""
          L = light.data
          L.use_custom_distance = True
          L.cutoff_distance = max(100.0, 20.0 * distance)
          ang = angular_size(light, distance)
          L.use_shadow_jitter = ang >= jitter_threshold_deg
          return ang
      
      
      def angular_size(light, distance):
          """Apparent size of the source from the target, degrees (the real softness control)."""
          L = light.data
          if L.type == "SUN":
              return math.degrees(L.angle)
          s = L.size if L.type == "AREA" else 2 * L.shadow_soft_size
          return math.degrees(2 * math.atan(s / 2 / max(distance, 1e-6)))
      
      
      def light_object(light):
          """The light OBJECT for a light object or a Light datablock. `light_linking` and
          `lightgroup` are Object properties: bpy.types.Light has neither (verified 5.2.1), so
          `light.data.light_linking` or a Light datablock named `light` raises AttributeError.
          A datablock used by several objects is ambiguous and raises TypeError."""
          if isinstance(light, bpy.types.Object):
              if light.type != "LIGHT":
                  raise TypeError(f"{light.name} is a {light.type} object, not a light")
              return light
          if isinstance(light, bpy.types.Light):
              users = [o for o in bpy.data.objects if o.data == light]
              if len(users) == 1:
                  return users[0]
              raise TypeError(f"Light datablock {light.name!r} is used by {len(users)} objects: pass the "
                              "light OBJECT (light_linking and lightgroup live on the Object)")
          raise TypeError(f"not a light: {light!r}")
      
      
      def link_light(light_obj, receivers, name=None):
          """Light linking: the light only lights `receivers` (objects or a collection). Works in
          EEVEE and Cycles (4.3+). Use for faked keys, rims and eye lights on a character.
          Lives on the light OBJECT (`obj.light_linking.receiver_collection`); a Light datablock
          is resolved to its single object by light_object()."""
          light_obj = light_object(light_obj)
          if receivers is None:
              light_obj.light_linking.receiver_collection = None
              return None
          if isinstance(receivers, bpy.types.Collection):
              light_obj.light_linking.receiver_collection = receivers
              return receivers
          cname = name or light_obj.name + "_receivers"
          rc = bpy.data.collections.get(cname) or bpy.data.collections.new(cname)
          for o in list(rc.objects):
              rc.objects.unlink(o)
          for o in _objs(receivers):
              rc.objects.link(o)
          light_obj.light_linking.receiver_collection = rc
          return rc
      
      
      def _make(name, ltype, target, az, el, dist, level, size, cam, temperature=None, color=None,
                space="camera", shape=None, avoid=None, link=None):
          ob = new_light(name, ltype)
          if shape and ltype == "AREA":
              ob.data.shape = shape
          dist = place(ob, target, az, el, dist, cam, space, avoid=avoid)
          ob.data.energy = energy_for(ltype, level, dist)
          set_size(ob, size)
          set_color(ob, temperature, color)
          eevee_softness(ob, dist)
          if link is not None:
              link_light(ob, link)
          return ob
      
      
      # ======================================================================== rigs
      def three_point(subject, cam=None, target=None, key_az=40.0, key_el=35.0, key_dist=None,
                      key_size=None, key_level=1.0, fill_ratio=0.25, fill_az=None, fill_el=10.0,
                      rim_level=2.0, rim_side="key", key_temp=None, fill_temp=None, rim_temp=None,
                      key_color=None, fill_color=None, rim_color=None, link_rim=False, prefix="BX_"):
          """Key / fill / rim placed relative to the subject and camera.
      
          Defaults: key 40 deg off the camera axis and 35 deg up (any angle but frontal, Gleb;
          from above for upper-hemisphere eye highlights, Andy), at 3 subject radii (close enough
          for falloff across the subject, Andrew/Gleb), source as big as the subject radius
          (form over texture, Andrew). Fill on the other side, lower, big and soft, at
          `fill_ratio` of the key level [added default 0.25; experts judge by eye, so measure
          with light_contributions]. Rim behind the subject on the KEY side (Andy: a shadow-side
          rim looks like a bounce from nowhere) unless rim_side="shadow" with a motivation.
          Lights are pulled in when a wall or prop sits on their path (free_distance); energies
          follow the distance actually used. link_rim=True light-links the rim to the subject.
          Returns {"key","fill","rim"} light objects. Idempotent (get-or-create by name).
          """
          cam = cam or bpy.context.scene.camera
          center, radius, _, _ = bounds(subject)
          tgt = _point(target) if target is not None else center
          key_dist = key_dist or 3.0 * radius
          key_size = key_size or 1.0 * radius
          side = 1 if key_az >= 0 else -1
          fill_az = fill_az if fill_az is not None else -side * 60.0
          rim_az = side * 145.0 if rim_side == "key" else -side * 145.0
          key = _make(prefix + "Key", "AREA", tgt, key_az, key_el, key_dist, key_level, key_size, cam,
                      key_temp, key_color, avoid=subject)
          fill = _make(prefix + "Fill", "AREA", tgt, fill_az, fill_el, key_dist * 1.2,
                       key_level * fill_ratio, key_size * 2.0, cam, fill_temp, fill_color, avoid=subject)
          rim = _make(prefix + "Rim", "AREA", tgt, rim_az, 30.0, key_dist, rim_level,
                      key_size * 0.4, cam, rim_temp, rim_color, avoid=subject,
                      link=subject if link_rim else None)
          return {"key": key, "fill": fill, "rim": rim}
      
      
      def motivated_interior(subject, window, practical=None, cam=None, target=None,
                             key="practical", key_level=1.2, practical_level=None, window_level=0.35,
                             bounce_level=0.08, rim_level=1.5, sky_color=(0.35, 0.5, 1.0),
                             practical_temp=2700, practical_radius=0.04, practical_cone=110.0,
                             cheat_key=True, link_key=True, link_rim=True, window_normal=None,
                             world_color=None, world_strength=None, prefix="BX_"):
          """Interior lit by what is in the room: a window (area light in the opening, colored by
          the sky) and a practical lamp (warm light at the bulb), plus a warm floor bounce and a
          rim motivated by whichever source sits behind the subject.
      
          window: (location, (width, height)) or an object (bbox center and two largest dims).
          key: "practical" (night/dusk interior: warm key, cool window fill) or "window" (day).
          practical_cone: the practical is a downward SPOT with this cone (a shaded hanging lamp;
            Andrew: convert to spot to carve light off what does not matter); 0 = bare point light.
          cheat_key: when the key source sits behind the subject (|azimuth| > 100 deg), add a faked
            key in the face from that side, colored like the source (Andy: in backlit scenes the
            rim becomes the main source but still add a faked key so the character reads); the
            practical then keeps `practical_level` (default half the key) for the set.
          link_key / link_rim: the cheated key and the rim only light the subject (light linking),
            so the set keeps its motivated look and the subject separates (Andy: darken the
            background a little; composition: add light only on the hero).
          Levels are [added] starting values: measure them with light_contributions.
          Returns dict of light objects.
          """
          sc = bpy.context.scene
          cam = cam or sc.camera
          center, radius, lo, hi = bounds(subject)
          tgt = _point(target) if target is not None else center
          if isinstance(window, bpy.types.Object):
              wc, _, wlo, whi = bounds(window)
              dims = sorted((whi - wlo)[:], reverse=True)
              wsize = (dims[0], dims[1])
          else:
              wc, wsize = Vector(window[0]), window[1]
          n = Vector(window_normal) if window_normal is not None else Vector((tgt.x - wc.x, tgt.y - wc.y, 0))
          n.normalize()
          rig = {}
          win = new_light(prefix + "Window", "AREA")
          win.data.shape = "RECTANGLE"
          win.data.size, win.data.size_y = wsize[0], wsize[1]
          win.location = wc + n * 0.02
          # emit along n (local -Z) with local Y up, so size_y is the window height
          win.rotation_mode = "XYZ"
          win.rotation_euler = n.to_track_quat("-Z", "Y").to_euler()
          d_win = (tgt - win.location).length
          lvl_win = key_level if key == "window" else window_level
          win.data.energy = energy_for("AREA", lvl_win, d_win) / max(0.2, n.dot((tgt - win.location).normalized()))
          set_color(win, color=sky_color)
          eevee_softness(win, d_win)
          rig["window"] = win
          if practical is not None:
              ptype = "SPOT" if practical_cone else "POINT"
              pr = new_light(prefix + "Practical", ptype)
              pr.location = _point(practical)
              if ptype == "SPOT":
                  aim(pr, pr.location - Vector((0, 0, 1)))
                  pr.data.spot_size = math.radians(practical_cone)
                  pr.data.spot_blend = 0.6
              d_pr = (tgt - pr.location).length
              lvl = key_level if key == "practical" else window_level
              pr.data.energy = energy_for(ptype, lvl, d_pr)
              set_size(pr, practical_radius)
              set_color(pr, temperature=practical_temp)
              eevee_softness(pr, d_pr)
              rig["practical"] = pr
          key_obj = rig["practical"] if (key == "practical" and practical is not None) else win
          ka = angles(key_obj, tgt, cam)
          if cheat_key and abs(ka["azimuth"]) > 100:
              side = 1 if ka["azimuth"] >= 0 else -1
              ck = new_light(prefix + "Key", "AREA")
              ck.data.shape = "DISK"
              dk = place(ck, tgt, side * 50.0, 30.0, 3.0 * radius, cam, avoid=subject)
              ck.data.energy = energy_for("AREA", key_level, dk)
              set_size(ck, 0.8 * radius)
              if key_obj.data.use_temperature:
                  set_color(ck, temperature=key_obj.data.temperature)
              else:
                  set_color(ck, color=key_obj.data.color)
              eevee_softness(ck, dk)
              link_light(ck, subject if link_key else None)
              rig["key"] = ck
              if key_obj is rig.get("practical"):
                  plev = practical_level if practical_level is not None else 0.5 * key_level
                  key_obj.data.energy = energy_for(key_obj.data.type, plev, ka["distance"])
              key_obj = ck
          # floor bounce: big, low, below the key direction, tinted by the key
          kdir = (key_obj.location - tgt)
          kdir_h = Vector((kdir.x, kdir.y, 0)).normalized()
          b = new_light(prefix + "Bounce", "AREA")
          b.data.shape = "DISK"
          b.data.size = max(1.0, 2.5 * radius)
          b.location = Vector((tgt.x, tgt.y, lo.z + 0.02)) + kdir_h * radius * 1.5
          aim(b, tgt)
          d_b = (tgt - b.location).length
          b.data.energy = energy_for("AREA", bounce_level, d_b)
          if key_obj.data.use_temperature:
              set_color(b, temperature=key_obj.data.temperature + 500)
          else:
              set_color(b, color=key_obj.data.color)
          eevee_softness(b, d_b)
          rig["bounce"] = b
          # rim motivated by the source that is most behind the subject; if none is behind,
          # rim on the key side (Andy: a shadow-side rim reads as a bounce from nowhere)
          srcs = [o for o in (rig.get("practical"), win) if o]
          back = max(srcs, key=lambda o: abs(angles(o, tgt, cam)["azimuth"]))
          a = angles(back, tgt, cam)
          if abs(a["azimuth"]) >= 100:
              rim_az = math.copysign(min(160.0, abs(a["azimuth"])), a["azimuth"])
              src = back
          else:
              rim_az = math.copysign(145.0, angles(key_obj, tgt, cam)["azimuth"])
              src = key_obj
          rim = new_light(prefix + "Rim", "SPOT")
          dr = place(rim, tgt, rim_az, max(15.0, min(45.0, a["elevation"])), 2.5 * radius, cam, avoid=subject)
          rim.data.spot_size = math.radians(40)
          rim.data.spot_blend = 0.5
          rim.data.energy = energy_for("SPOT", rim_level, dr)
          set_size(rim, 0.1 * radius)
          if src.data.use_temperature:
              set_color(rim, temperature=src.data.temperature)
          else:
              set_color(rim, color=src.data.color)
          eevee_softness(rim, dr)
          link_light(rim, subject if link_rim else None)
          rig["rim"] = rim
          # a source almost straight behind (within 15 deg) backlights both edges: second rim
          old = bpy.data.objects.get(prefix + "Rim2")
          if abs(a["azimuth"]) >= 165 and src is back:
              rim2 = new_light(prefix + "Rim2", "SPOT")
              dr2 = place(rim2, tgt, -rim_az, max(15.0, min(45.0, a["elevation"])), 2.5 * radius, cam,
                          avoid=subject)
              rim2.data.spot_size, rim2.data.spot_blend = rim.data.spot_size, rim.data.spot_blend
              rim2.data.energy = energy_for("SPOT", rim_level, dr2)
              set_size(rim2, 0.1 * radius)
              rim2.data.use_temperature, rim2.data.temperature = rim.data.use_temperature, rim.data.temperature
              rim2.data.color = rim.data.color
              eevee_softness(rim2, dr2)
              link_light(rim2, subject if link_rim else None)
              rig["rim2"] = rim2
          elif old is not None:
              old.hide_render = True
          if world_color is not None or world_strength is not None:
              bg = sc.world.node_tree.nodes.get("Background")
              if world_color is not None:
                  bg.inputs["Color"].default_value = (*world_color[:3], 1)
              if world_strength is not None:
                  bg.inputs["Strength"].default_value = world_strength
          return rig
      
      
      def outdoor_sun_sky(subject, cam=None, target=None, sun_azimuth=45.0, sun_elevation=35.0,
                          azimuth_space="camera", sun_level=2.0, sun_angle=2.0, sky_strength=0.25,
                          sky_type="MULTIPLE_SCATTERING", bounce_level=0.0, prefix="BX_"):
          """Sun lamp plus physical sky, synced (same azimuth/elevation), sky sun disc off and
          EEVEE HDRI-sun extraction off so the sun is not doubled (Andrew: threshold 0, own sun
          lamp for control). Sun angle 2 deg by default (Andy's Sprite Fright demo).
          azimuth_space="world" pins the sun to the set for continuity across shots (Andy: sun
          direction relative to the set, about 30 deg leeway); "camera" uses the lighting
          convention (0 = frontal). Optional warm ground bounce card for the shadow side.
          """
          sc = bpy.context.scene
          cam = cam or sc.camera
          center, radius, lo, hi = bounds(subject)
          tgt = _point(target) if target is not None else center
          d = direction(sun_azimuth, sun_elevation, tgt, cam, azimuth_space)
          sun = new_light(prefix + "Sun", "SUN")
          sun.location = tgt + d * 10.0
          aim(sun, tgt)
          sun.data.energy = energy_for("SUN", sun_level)
          sun.data.angle = math.radians(sun_angle)
          sun.data.use_shadow_jitter = sun_angle >= 3.0
          world_az = math.degrees(math.atan2(d.x, d.y)) % 360
          world_el = math.degrees(math.asin(d.z))
          w = sc.world
          nt = w.node_tree
          bg = nt.nodes.get("Background")
          sky = nt.nodes.get("BX_Sky") or nt.nodes.new("ShaderNodeTexSky")
          sky.name = "BX_Sky"
          sky.sky_type = sky_type
          sky.sun_disc = False
          sky.sun_elevation = math.radians(max(world_el, 0.0))
          sky.sun_rotation = math.radians(world_az)
          nt.links.new(bg.inputs["Color"], sky.outputs["Color"])
          bg.inputs["Strength"].default_value = sky_strength
          w.sun_threshold = 0.0            # EEVEE: do not extract a second sun from the sky
          rig = {"sun": sun, "sky": sky, "sun_world": {"azimuth": round(world_az, 1), "elevation": round(world_el, 1)}}
          if bounce_level > 0:
              b = new_light(prefix + "GroundBounce", "AREA")
              b.data.size = 3 * radius
              dh = Vector((d.x, d.y, 0)).normalized()
              b.location = Vector((tgt.x, tgt.y, lo.z + 0.01)) - dh * radius * 1.5
              aim(b, tgt)
              db = (tgt - b.location).length
              b.data.energy = energy_for("AREA", bounce_level, db)
              set_color(b, color=(1.0, 0.8, 0.6))
              eevee_softness(b, db)
              rig["bounce"] = b
          return rig
      
      
      def fake_sun(subject, sun_obj, target=None, distance_factor=60.0, level=None, prefix="BX_"):
          """Lino Thomas' fake second sun: a spot with a ~1 deg cone far away along the sun
          direction, so the subject gets its own parallel key while the real sun lights the
          background (block the real sun off the subject with shadow_caster)."""
          _sync()
          center, radius, _, _ = bounds(subject)
          tgt = _point(target) if target is not None else center
          z = (sun_obj.matrix_world.to_3x3() @ Vector((0, 0, 1))).normalized()
          D = distance_factor * radius
          sp = new_light(prefix + "FakeSun", "SPOT")
          sp.location = tgt + z * D
          aim(sp, tgt)
          sp.data.spot_size = 2 * math.atan(1.3 * radius / D)
          sp.data.spot_blend = 0.3
          lvl = level if level is not None else sun_obj.data.energy * K_WHITE["SUN"]
          sp.data.energy = energy_for("SPOT", lvl, D)
          sp.data.shadow_soft_size = 0.0
          set_color(sp, color=sun_obj.data.color)
          return sp
      
      
      def shadow_caster(light_obj, subject, target=None, frac=0.35, size=None, name="BX_ShadowCaster"):
          """Andy Goralczyk's blocker: a near-black plane between a light and the subject,
          invisible to camera and reflections, that only removes that light from the subject."""
          _sync()
          center, radius, _, _ = bounds(subject)
          tgt = _point(target) if target is not None else center
          if light_obj.data.type == "SUN":
              src = tgt + (light_obj.matrix_world.to_3x3() @ Vector((0, 0, 1))).normalized() * 6 * radius
          else:
              src = light_obj.matrix_world.translation
          ob = bpy.data.objects.get(name)
          if ob is None:
              me = bpy.data.meshes.new(name)
              me.from_pydata([(-.5, -.5, 0), (.5, -.5, 0), (.5, .5, 0), (-.5, .5, 0)], [], [(0, 1, 2, 3)])
              ob = bpy.data.objects.new(name, me)
              _collection(LIGHT_COLL).objects.link(ob)
              m = bpy.data.materials.new(name)
              p = m.node_tree.nodes["Principled BSDF"]
              p.inputs["Base Color"].default_value = (0.01, 0.01, 0.01, 1)
              p.inputs["Roughness"].default_value = 1.0
              me.materials.append(m)
          ob.location = tgt + (src - tgt) * frac
          aim(ob, tgt)
          s = size or 3.0 * radius
          ob.scale = (s, s, 1)
          ob.visible_camera = False
          ob.visible_glossy = False
          ob.visible_transmission = False
          return ob
      
      
      def eye_light(eyes, cam=None, distance=None, level=0.6, size=0.05, elevation=12.0, azimuth=10.0,
                    name="BX_EyeLight"):
          """Catchlight only in the eyes: tiny area light near the camera, a bit above (upper half
          of the eye, Andy), diffuse off so only the glint shows, light-linked to the eyes (Gleb)."""
          cam = cam or bpy.context.scene.camera
          eyes = _objs(eyes)
          center, radius, _, _ = bounds(eyes)
          dist = distance or max(1.0, 0.6 * (cam.matrix_world.translation - center).length)
          ob = new_light(name, "AREA")
          place(ob, center, azimuth, elevation, dist, cam)
          ob.data.size = size
          ob.data.energy = energy_for("AREA", level, dist)
          ob.data.diffuse_factor = 0.0
          ob.data.specular_factor = 1.0
          ob.data.volume_factor = 0.0
          link_light(ob, eyes)
          return ob
      
      
      def split_world(camera_color=(0.02, 0.02, 0.03), camera_strength=1.0, world=None):
          """World seen by the camera separate from the world that lights the scene (Light Path
          Is Camera Ray): darken or swap the backdrop without darkening the subject (Gleb, Andy)."""
          w = world or bpy.context.scene.world
          nt = w.node_tree
          out = next(n for n in nt.nodes if n.type == "OUTPUT_WORLD")
          light_bg = nt.nodes.get("Background")
          cam_bg = nt.nodes.get("BX_CameraBG") or nt.nodes.new("ShaderNodeBackground")
          cam_bg.name = "BX_CameraBG"
          lp = nt.nodes.get("BX_LightPath") or nt.nodes.new("ShaderNodeLightPath")
          lp.name = "BX_LightPath"
          mix = nt.nodes.get("BX_WorldMix") or nt.nodes.new("ShaderNodeMixShader")
          mix.name = "BX_WorldMix"
          cam_bg.inputs["Color"].default_value = (*camera_color[:3], 1)
          cam_bg.inputs["Strength"].default_value = camera_strength
          nt.links.new(mix.inputs["Fac"], lp.outputs["Is Camera Ray"])
          nt.links.new(mix.inputs[1], light_bg.outputs["Background"])
          nt.links.new(mix.inputs[2], cam_bg.outputs["Background"])
          nt.links.new(out.inputs["Surface"], mix.outputs["Shader"])
          return mix
      
      
      def world_gradient(top=(0.25, 0.4, 0.9), bottom=(0.35, 0.25, 0.12), strength=0.5, world=None):
          """Andy Goralczyk's lighting world: blue from above (sky/canopy), warm from below
          (ground bounce). Drives the lighting Background; combine with split_world."""
          w = world or bpy.context.scene.world
          nt = w.node_tree
          bg = nt.nodes.get("Background")
          tc = nt.nodes.get("BX_TexCoord") or nt.nodes.new("ShaderNodeTexCoord")
          tc.name = "BX_TexCoord"
          sep = nt.nodes.get("BX_SepZ") or nt.nodes.new("ShaderNodeSeparateXYZ")
          sep.name = "BX_SepZ"
          mr = nt.nodes.get("BX_MapZ") or nt.nodes.new("ShaderNodeMapRange")
          mr.name = "BX_MapZ"
          mix = nt.nodes.get("BX_GradMix") or nt.nodes.new("ShaderNodeMix")
          mix.name = "BX_GradMix"
          mix.data_type = "RGBA"
          nt.links.new(sep.inputs[0], tc.outputs["Generated"])
          nt.links.new(mr.inputs["Value"], sep.outputs["Z"])
          mr.inputs["From Min"].default_value, mr.inputs["From Max"].default_value = -1.0, 1.0
          nt.links.new(mix.inputs["Factor"], mr.outputs["Result"])
          mix.inputs[6].default_value = (*bottom[:3], 1)
          mix.inputs[7].default_value = (*top[:3], 1)
          nt.links.new(bg.inputs["Color"], mix.outputs[2])
          bg.inputs["Strength"].default_value = strength
          return mix
      
      
      def haze(extent_objs=None, lo=None, hi=None, density=0.05, anisotropy=0.2, color=(1, 1, 1),
               name="BX_Haze"):
          """Room-sized volume cube, drawn as bounds (Gleb, Andrew: default densities are "always
          way too high", 0.05 for a room). Excluded from volume-probe bakes (Andrew's "hide before
          baking" as a flag). Positive anisotropy scatters toward the light (Lino)."""
          if extent_objs is not None:
              _, _, lo, hi = bounds(extent_objs)
          lo, hi = Vector(lo), Vector(hi)
          ob = bpy.data.objects.get(name)
          if ob is None:
              me = bpy.data.meshes.new(name)
              v = [(x, y, z) for x in (-.5, .5) for y in (-.5, .5) for z in (-.5, .5)]
              f = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
              me.from_pydata(v, [], f)
              ob = bpy.data.objects.new(name, me)
              bpy.context.scene.collection.objects.link(ob)
              m = bpy.data.materials.new(name)
              nt = m.node_tree
              nt.nodes.remove(nt.nodes["Principled BSDF"])
              vol = nt.nodes.new("ShaderNodeVolumePrincipled")
              nt.links.new(nt.nodes["Material Output"].inputs["Volume"], vol.outputs[0])
              me.materials.append(m)
          vol = next(n for n in ob.active_material.node_tree.nodes if n.bl_idname == "ShaderNodeVolumePrincipled")
          vol.inputs["Density"].default_value = density
          vol.inputs["Anisotropy"].default_value = anisotropy
          vol.inputs["Color"].default_value = (*color[:3], 1)
          ob.location = (lo + hi) / 2
          ob.scale = hi - lo
          ob.display_type = "BOUNDS"
          ob.hide_probe_volume = True
          bpy.context.scene.eevee.use_volumetric_shadows = True
          return ob
      
      
      def _source_m(L):
          """Physical source size in metres: area edge (largest), 2 x radius for point/spot."""
          if L.type == "AREA":
              return max(L.size, L.size_y if L.shape in {"RECTANGLE", "ELLIPSE"} else L.size)
          if L.type in {"POINT", "SPOT"}:
              return 2 * L.shadow_soft_size
          return None
      
      
      def light_report(scene=None, target=None, cam=None, lights=None, key=None, face=None):
          """One line per light: type, az/el vs camera, distance, angular size, source size in
          metres, nominal level at the target, color. Flags a frontal key (< 20 deg off the
          camera axis) [added threshold] and a key from below (eye highlights must sit in the
          upper half, Andy). face (head object(s)): also flags a key source smaller than the
          head (Andrew Price: large sources for faces so form reads and pores do not; small ones
          for grit, aggression or a sun/streetlamp cue; 'head-sized' is the [added] threshold;
          ignore the flag for NPR/toon keys). The key is `key` (name) or the strongest diffuse
          light in the front hemisphere (|azimuth| <= 100; rims carry high nominal levels because
          they graze)."""
          _sync()
          sc = _scene(scene)
          cam = cam or sc.camera
          lights = lights or [o for o in sc.objects if o.type == "LIGHT" and not o.hide_render]
          tgt = _point(target) if target is not None else Vector((0, 0, 0))
          rows, flags = [], []
          for o in lights:
              if o.data.type == "SUN":
                  z = (o.matrix_world.to_3x3() @ Vector((0, 0, 1))).normalized()
                  h, right = _cam_axes(cam, tgt)
                  a = {"azimuth": round(math.degrees(math.atan2(z.dot(right), z.dot(h))), 1),
                       "elevation": round(math.degrees(math.asin(max(-1, min(1, z.z)))), 1),
                       "distance": None,
                       "off_camera_axis": round(math.degrees(z.angle((cam.matrix_world.translation - tgt).normalized())), 1)}
              else:
                  a = angles(o, tgt, cam)
              dist = a["distance"] or 1.0
              rows.append({"name": o.name, "type": o.data.type, **a,
                           "angular_size_deg": round(angular_size(o, dist), 2),
                           "source_m": None if _source_m(o.data) is None else round(_source_m(o.data), 3),
                           "level": round(level_of(o, tgt), 3),
                           "kelvin": o.data.temperature if o.data.use_temperature else None,
                           "rgb": [round(c, 3) for c in o.data.color],
                           "jitter": o.data.use_shadow_jitter,
                           "diffuse": o.data.diffuse_factor})
          if rows:
              if key is not None:
                  key = next((r for r in rows if r["name"] == key), None)
              else:
                  front = [r for r in rows if r["diffuse"] > 0 and abs(r["azimuth"]) <= 100]
                  key = max(front or [r for r in rows if r["diffuse"] > 0], key=lambda r: r["level"], default=None)
              if key:
                  if key["off_camera_axis"] < 20:
                      flags.append(f"key {key['name']} is frontal ({key['off_camera_axis']} deg off the camera axis): flat light")
                  if key["elevation"] < 0:
                      flags.append(f"key {key['name']} is below the eye line: eye highlights in the lower half")
                  if face is not None and key["source_m"] is not None:
                      _, _, flo, fhi = bounds(face)
                      head = max(fhi - flo)
                      if key["source_m"] < head:
                          flags.append(f"key {key['name']} is a small source for a face ({key['source_m']:.2f} m vs a "
                                       f"{head:.2f} m head): pores and texture read; Andrew: large sources for faces, "
                                       "small for grit, aggression or a sun/streetlamp cue (fine for NPR/toon)")
          return {"lights": rows, "flags": flags}
      
      
      # ======================================================================== render presets
      def cycles_gpu(scene=None):
          """Use the first GPU backend available (Metal, OptiX, CUDA, HIP, oneAPI); CPU otherwise."""
          sc = _scene(scene)
          prefs = bpy.context.preferences.addons["cycles"].preferences
          for t in ("OPTIX", "CUDA", "HIP", "METAL", "ONEAPI"):
              try:
                  prefs.compute_device_type = t
              except TypeError:
                  continue
              prefs.get_devices()
              gpus = [d for d in prefs.devices if d.type != "CPU"]
              if gpus:
                  for d in prefs.devices:
                      d.use = d.type != "CPU"
                  sc.cycles.device = "GPU"
                  return t
          sc.cycles.device = "CPU"
          return "CPU"
      
      
      def preset_eevee(scene=None, quality="preview", interior=False, animation=False, pct=None):
          """EEVEE (5.x id 'BLENDER_EEVEE') with the experts' switches. Ray tracing is OFF in a
          factory scene and must be on (Andrew, Gleb). Fast GI steps 16 minimum (Andrew). Final
          resolutions 1:1 reflections, 1:2 GI (1/16 flickers). Shadow steps 12 for thin-wall leaks
          in interiors, shadow rays 2 against penumbra noise instead of more samples (Andrew).
          Temporal denoise off for stills keeps textures sharp (Andrew). Sample counts [added]."""
          sc = _scene(scene)
          sc.render.engine = "BLENDER_EEVEE"
          ee = sc.eevee
          ro = ee.ray_tracing_options
          ee.use_raytracing = True
          ee.ray_tracing_method = "SCREEN"
          ro.trace_max_roughness = 0.5
          ee.use_fast_gi = True
          ee.fast_gi_method = "GLOBAL_ILLUMINATION"
          ee.fast_gi_step_count = 16
          ee.use_shadows = True
          final = quality == "final"
          ee.taa_render_samples = 128 if final else 32
          ro.resolution_scale = "1" if final else "2"
          ee.fast_gi_resolution = "2" if final else "4"
          ee.shadow_ray_count = 2 if final else 1
          ee.shadow_step_count = 12 if interior else 6
          ro.use_denoise = True
          ro.denoise_temporal = animation
          if pct is None:
              pct = 100 if final else 50
          sc.render.resolution_percentage = pct
          return {"engine": "BLENDER_EEVEE", "samples": ee.taa_render_samples, "rt_scale": ro.resolution_scale,
                  "fast_gi_res": ee.fast_gi_resolution, "shadow_rays": ee.shadow_ray_count,
                  "shadow_steps": ee.shadow_step_count, "pct": pct}
      
      
      def preset_cycles(scene=None, quality="preview", budget="film", glass=False, gpu=True, pct=None):
          """Cycles presets. budget="film" is Andy Goralczyk's Sprite Fright economy: 3 bounces
          (bounces faked with cards), no caustics, no clamping, Fast GI replace (10 to 30 %
          faster), adaptive sampling, OIDN; tests about 100 samples, finals 500 to 1,500.
          budget="default" keeps Blender's light paths (12 bounces, clamp 10, caustics on).
          glass=True keeps 8 transmission bounces [added] (3 turns thick glass black)."""
          sc = _scene(scene)
          sc.render.engine = "CYCLES"
          cy = sc.cycles
          dev = cycles_gpu(sc) if gpu else "CPU"
          final = quality == "final"
          cy.use_adaptive_sampling = True
          cy.samples = 1024 if final else 128
          cy.adaptive_threshold = 0.01 if final else 0.03
          cy.use_denoising = True
          cy.denoiser = "OPENIMAGEDENOISE"
          cy.denoising_input_passes = "RGB_ALBEDO_NORMAL"
          if budget == "film":
              cy.max_bounces = 3
              cy.diffuse_bounces = 3
              cy.glossy_bounces = 3
              cy.transmission_bounces = 8 if glass else 3
              cy.max_bounces = max(cy.max_bounces, cy.transmission_bounces)
              cy.volume_bounces = 0
              cy.caustics_reflective = cy.caustics_refractive = False
              cy.sample_clamp_indirect = 0.0
              cy.use_fast_gi = True
              cy.fast_gi_method = "REPLACE"
              cy.ao_bounces_render = 1
          cy.use_light_tree = True
          if pct is None:
              pct = 100 if final else 50
          sc.render.resolution_percentage = pct
          return {"engine": "CYCLES", "device": dev, "samples": cy.samples, "threshold": cy.adaptive_threshold,
                  "max_bounces": cy.max_bounces, "fast_gi": cy.use_fast_gi, "pct": pct}
      
      
      # ======================================================================== color management
      def color_setup(scene=None, view="AgX", look=None, exposure=0.0, gamma=1.0, display="sRGB",
                      white_balance=None):
          """View transform + look + exposure. Looks are prefixed per view in 5.x ('AgX - Punchy');
          this accepts 'Punchy' and adds the prefix. ACES 1.3/2.0 and False Color have no looks.
          Global brightness goes in `exposure`, not in scaled lights (Jacob Holiday): one
          reversible knob that keeps every ratio, including the world and emissive surfaces a
          light-scaling pass forgets (measured in test_12: exposure +1 equals every emitter x2,
          lamps-only x2 does not). View: AgX for scenes; 'Khronos PBR Neutral' when a product or
          brand colour must display as authored (Jacob; measured in test_12: base colour kept
          within a few 8-bit steps in a white furnace, AgX shifts it); not for skin (Gleb:
          oversaturates)."""
          sc = _scene(scene)
          sc.display_settings.display_device = display
          vs = sc.view_settings
          vs.view_transform = view
          if look in (None, "", "None"):
              vs.look = "None"
          else:
              for cand in (look, f"{view} - {look}"):
                  try:
                      vs.look = cand
                      break
                  except TypeError:
                      continue
              else:
                  raise ValueError(f"look {look!r} not valid for view {view!r}")
          vs.exposure = exposure
          vs.gamma = gamma
          if white_balance:
              vs.use_white_balance = True
              vs.white_balance_temperature = white_balance
          return {"view": vs.view_transform, "look": vs.look, "exposure": vs.exposure, "display": display}
      
      
      def working_space(name="Linear Rec.709", convert_colors=True):
          """Set the file's working space once at project start (read-only property in 5.x; the
          operator converts existing colors). Options: 'Linear Rec.709', 'Linear Rec.2020', 'ACEScg'.
          Rec.2020 over ACEScg unless exchanging ACES (5.0 overview); the space changes renders
          (Vegdahl), so never switch mid-project."""
          cur = bpy.data.colorspace.working_space
          if cur != name:
              bpy.ops.wm.set_working_color_space(working_space=name, convert_colors=convert_colors)
          return bpy.data.colorspace.working_space
      
      
      def color_audit(scene=None):
          """Jacob Holiday's pipeline traps as checks. Returns a list of problems (strings)."""
          sc = _scene(scene)
          probs = []
          vs = sc.view_settings
          if vs.view_transform == "Standard":
              probs.append("view transform Standard clips highlights above 1.0: use AgX/ACES/Khronos PBR Neutral")
          for m in bpy.data.materials:
              if not m.node_tree or m.users == 0:
                  continue
              for link in m.node_tree.links:
                  n = link.from_node
                  if n.bl_idname != "ShaderNodeTexImage" or n.image is None:
                      continue
                  cs = n.image.colorspace_settings.name
                  to = link.to_socket.name
                  to_node = link.to_node.bl_idname
                  data_dest = to in DATA_INPUTS or to_node in {"ShaderNodeNormalMap", "ShaderNodeBump",
                                                               "ShaderNodeDisplacement"}
                  if data_dest and cs != "Non-Color":
                      probs.append(f"{m.name}: image '{n.image.name}' feeds {to} but is '{cs}' (use Non-Color)")
                  if to in COLOR_INPUTS and to_node == "ShaderNodeBsdfPrincipled" and cs == "Non-Color":
                      probs.append(f"{m.name}: image '{n.image.name}' feeds {to} but is Non-Color")
          ims = sc.render.image_settings
          if ims.file_format in {"PNG", "JPEG", "TIFF"} and ims.color_management == "OVERRIDE":
              probs.append("display-format output overrides color management: finals should Follow Scene")
          for cam in (o for o in sc.objects if o.type == "CAMERA"):
              for bgi in cam.data.background_images:
                  if bgi.image and not bgi.image.use_view_as_render:
                      probs.append(f"camera {cam.name}: background image not View as Render")
          return probs
      
      
      # ======================================================================== compositor (5.x)
      def comp_tree(scene=None, name="BX_Comp", reset=True):
          """Get-or-create the compositing node group (5.x: scene.compositing_node_group, Group
          Output replaces Composite). Returns (tree, render_layers_node, group_output_node)."""
          sc = _scene(scene)
          tree = bpy.data.node_groups.get(name)
          if tree is None:
              tree = bpy.data.node_groups.new(name, "CompositorNodeTree")
          if reset:
              tree.nodes.clear()
          if not any(s.in_out == "OUTPUT" for s in tree.interface.items_tree if s.item_type == "SOCKET"):
              tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor")
          rl = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None) or \
              tree.nodes.new("CompositorNodeRLayers")
          out = next((n for n in tree.nodes if n.bl_idname == "NodeGroupOutput"), None) or \
              tree.nodes.new("NodeGroupOutput")
          rl.location, out.location = (-600, 0), (900, 0)
          sc.compositing_node_group = tree
          sc.render.use_compositing = True
          return tree, rl, out
      
      
      def load_comp_asset(name):
          """Append a built-in compositor asset (Vignette, Tune Image, Film Grain, Chromatic
          Aberration, Sensor Noise, Split Toning, Unsharp Mask...) once and return the group."""
          ng = bpy.data.node_groups.get(name)
          if ng is not None:
              return ng
          path = os.path.join(bpy.utils.system_resource("DATAFILES"), ESSENTIALS)
          with bpy.data.libraries.load(path, link=False) as (src, dst):
              if name not in src.node_groups:
                  raise KeyError(f"{name} not in {path}: {src.node_groups}")
              dst.node_groups = [name]
          return bpy.data.node_groups[name]
      
      
      def _mixrgb(tree, blend, a, b, fac=1.0):
          m = tree.nodes.new("ShaderNodeMix")
          m.data_type = "RGBA"
          m.blend_type = blend
          m.inputs["Factor"].default_value = fac
          tree.links.new(m.inputs[6], a)
          tree.links.new(m.inputs[7], b)
          return m.outputs[2]
      
      
      def back_to_beauty(tree, rl, volume=False):
          """Robin Ruud's rebuild: sum over Diffuse/Glossy/Transmission of (Direct + Indirect) x
          Color, plus Emission and Environment (+ Volume Direct/Indirect). Needs those passes on
          the view layer. Returns the rebuilt color socket (exact to 1e-8 in Cycles)."""
          acc = None
          for c in ("Diffuse", "Glossy", "Transmission"):
              comp = _mixrgb(tree, "MULTIPLY", _mixrgb(tree, "ADD", rl.outputs[f"{c} Direct"],
                                                       rl.outputs[f"{c} Indirect"]), rl.outputs[f"{c} Color"])
              acc = comp if acc is None else _mixrgb(tree, "ADD", acc, comp)
          acc = _mixrgb(tree, "ADD", _mixrgb(tree, "ADD", acc, rl.outputs["Emission"]), rl.outputs["Environment"])
          if volume:
              acc = _mixrgb(tree, "ADD", acc, _mixrgb(tree, "ADD", rl.outputs["Volume Direct"],
                                                      rl.outputs["Volume Indirect"]))
          return acc
      
      
      def enable_beauty_passes(view_layer=None, volume=False):
          vl = view_layer or bpy.context.view_layer
          for c in ("diffuse", "glossy", "transmission"):
              for k in ("direct", "indirect", "color"):
                  setattr(vl, f"use_pass_{c}_{k}", True)
          vl.use_pass_emit = vl.use_pass_environment = True
          if volume:
              vl.cycles.use_pass_volume_direct = vl.cycles.use_pass_volume_indirect = True
      
      
      def comp_finish(scene=None, bloom=0.2, threshold=1.0, bloom_size=0.5, vignette=0.25,
                      feather=0.4, contrast=0.0, color_boost=0.0, exposure=0.0, source=None,
                      quality="High"):
          """Pau Homs' minimum finish: a bit of bloom, a vignette, then a correction to give back
          the punch bloom takes; stop before it reads as a filter. Chain: [source or Render
          Layers Image] > Glare (Bloom) > Vignette asset > Tune Image asset > Exposure > Output.
          Any argument set to 0/None skips its node. Returns dict of nodes."""
          sc = _scene(scene)
          if source is not None:          # continue the tree that owns the source (back_to_beauty, lightgroup_mix)
              tree = source.id_data
              out = next(n for n in tree.nodes if n.bl_idname == "NodeGroupOutput")
              rl = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None)
          else:
              tree, rl, out = comp_tree(sc, reset=True)
          sock = source if source is not None else rl.outputs["Image"]
          nodes = {"tree": tree, "rl": rl, "out": out}
          x = -300
          if bloom:
              g = tree.nodes.new("CompositorNodeGlare")
              g.inputs["Type"].default_value = "Bloom"
              g.inputs["Quality"].default_value = quality
              g.inputs["Threshold"].default_value = threshold
              g.inputs["Strength"].default_value = bloom
              g.inputs["Size"].default_value = bloom_size
              tree.links.new(g.inputs["Image"], sock)
              sock, nodes["glare"] = g.outputs["Image"], g
              g.location = (x, 0); x += 250
          if vignette:
              v = tree.nodes.new("CompositorNodeGroup")
              v.node_tree = load_comp_asset("Vignette")
              v.inputs["Factor"].default_value = vignette
              v.inputs["Feather"].default_value = feather
              tree.links.new(v.inputs["Image"], sock)
              sock, nodes["vignette"] = v.outputs[0], v
              v.location = (x, 0); x += 250
          if contrast or color_boost:
              t = tree.nodes.new("CompositorNodeGroup")
              t.node_tree = load_comp_asset("Tune Image")
              t.inputs["Contrast"].default_value = contrast
              t.inputs["Color Boost"].default_value = color_boost
              tree.links.new(t.inputs["Image"], sock)
              sock, nodes["tune"] = t.outputs[0], t
              t.location = (x, 0); x += 250
          if exposure:
              e = tree.nodes.new("CompositorNodeExposure")
              e.inputs["Exposure"].default_value = exposure
              tree.links.new(e.inputs["Image"], sock)
              sock, nodes["exposure"] = e.outputs["Image"], e
              e.location = (x, 0); x += 250
          tree.links.new(out.inputs[0], sock)
          return nodes
      
      
      def matte(tree, names, scene=None):
          """Cryptomatte matte socket for objects by name (no eyedropper): drive a Factor with it.
          Enables the object Cryptomatte pass on the active view layer."""
          sc = _scene(scene)
          bpy.context.view_layer.use_pass_cryptomatte_object = True
          cm = tree.nodes.new("CompositorNodeCryptomatteV2")
          cm.scene = sc
          cm.matte_id = ", ".join(names)
          return cm.outputs["Matte"]
      
      
      def relative_px(tree, image_socket, fraction, dimension="X"):
          """Relative To Pixel: express a pixel-unit input as a fraction of the image (Pau Homs)
          so viewport and final render match. Returns the float output socket."""
          rp = tree.nodes.new("CompositorNodeRelativeToPixel")
          rp.data_type = "FLOAT"
          rp.reference_dimension = dimension
          tree.links.new(rp.inputs["Image"], image_socket)
          rp.inputs[1].default_value = fraction
          return rp.outputs[1]
      
      
      def file_output(tree, directory, items, file_name="pass_", multilayer=True, depth="16"):
          """File Output node (5.x API: directory, file_name, file_output_items). items maps
          name -> socket. Multilayer EXR half float by default."""
          fo = tree.nodes.new("CompositorNodeOutputFile")
          fo.directory = directory
          fo.file_name = file_name
          if multilayer:
              fo.format.media_type = "MULTI_LAYER_IMAGE"
              fo.format.color_depth = depth
              fo.format.exr_codec = "DWAA"
          for name, sock in items.items():
              typ = "FLOAT" if sock.type == "VALUE" else "RGBA"
              fo.file_output_items.new(typ, name)
              tree.links.new(fo.inputs[name], sock)
          return fo
      
      
      # ======================================================================== light groups (Cycles)
      def _emits(ob):
          """True when a mesh object's material emits (Principled emission or an Emission node)."""
          if ob.type != "MESH":
              return False
          for slot in ob.material_slots:
              m = slot.material
              if m is None or m.node_tree is None:
                  continue
              for n in m.node_tree.nodes:
                  if n.bl_idname == "ShaderNodeBsdfPrincipled":
                      s, c = n.inputs["Emission Strength"], n.inputs["Emission Color"]
                      if (s.is_linked or s.default_value > 0) and (c.is_linked or max(c.default_value[:3]) > 0):
                          return True
                  elif n.bl_idname == "ShaderNodeEmission":
                      if n.inputs["Strength"].is_linked or n.inputs["Strength"].default_value > 0:
                          return True
          return False
      
      
      def light_groups(groups, world=None, rest="rest", scene=None, view_layer=None):
          """Light groups: render once, then rebalance key, fill, rim, window in the compositor
          without re-rendering (Wyatt: separate what you may grade independently, M4v_hfGF4EM
          00:07:57; Robin: studios split light so late changes need no 3D re-render,
          vtdczoXVyvQ 00:32:55). CYCLES ONLY in 5.2.1: EEVEE's Render Layers node exposes no
          Combined_<group> outputs (verified); in EEVEE use light linking and view layers.
      
          groups: {"key": [light objects], "window": [...], ...}. The group lives on the OBJECT
          (`obj.lightgroup`, like light_linking; a Light datablock is resolved by light_object)
          and on the world (`world.lightgroup`). Emissive meshes are lights too. Every light and
          emissive mesh not listed, and the world when `world` is None, go to `rest`, so the
          groups add back up to the beauty (rest=None leaves them out and reports them).
          Creates the view layer's lightgroups. Returns {"groups", "world", "unassigned"}."""
          sc = _scene(scene)
          vl = view_layer or (bpy.context.view_layer if sc == bpy.context.scene else sc.view_layers[0])
          listed, out = set(), {}
          for g, members in groups.items():
              if g not in vl.lightgroups:
                  vl.lightgroups.add(name=g)
              for m in members if isinstance(members, (list, tuple, set)) else [members]:
                  if m is None:
                      continue
                  ob = light_object(m) if isinstance(m, bpy.types.Light) else m
                  ob.lightgroup = g
                  listed.add(ob.name)
                  out.setdefault(g, []).append(ob.name)
          unassigned = []
          for o in sc.objects:
              if o.name in listed or not (o.type == "LIGHT" or _emits(o)):
                  continue
              if rest:
                  o.lightgroup = rest
                  out.setdefault(rest, []).append(o.name)
              else:
                  o.lightgroup = ""
                  unassigned.append(o.name)
          if sc.world is not None:
              sc.world.lightgroup = world or rest or ""
              if not sc.world.lightgroup:
                  unassigned.append("World")
          for g in set(out) | ({sc.world.lightgroup} if sc.world is not None and sc.world.lightgroup else set()):
              if g not in vl.lightgroups:
                  vl.lightgroups.add(name=g)
          return {"groups": out, "world": sc.world.lightgroup if sc.world is not None else None,
                  "unassigned": unassigned}
      
      
      def _exr_parts(path):
          inp = oiio.ImageInput.open(path)
          if inp is None:
              raise IOError(oiio.geterror())
          names, i = [], 0
          try:
              while inp.seek_subimage(i, 0):
                  names.append(inp.spec().getattribute("name") or "")
                  i += 1
          finally:
              inp.close()
          return names
      
      
      def lightgroup_passes(exr_path):
          """{'Combined': HxWx3, '<group>': HxWx3, ...} linear, from a Cycles multilayer EXR written
          by render() (parts '<ViewLayer>.Combined_<group>'; composite parts are skipped)."""
          out = {}
          for n in _exr_parts(exr_path):
              if n.startswith("Composite."):
                  continue
              tail = n.split(".", 1)[-1]
              if tail == "Combine
  • SKILL.md 19.7 KB
    ---
    name: scenario-blender-lighting-rendering
    description: "Use when lighting, rendering or compositing in Blender: light a character, product or hero shot, interior at dusk or night, three-point or motivated lighting, sun and sky, HDRI, EEVEE vs Cycles settings, light leaks or noise, flat or muddy renders, subject not standing out, AgX/ACES color management, render passes, EXR, light groups, bloom and vignette in the 5.x compositor, rule of thirds and value checks, toon/NPR looks, or judging a render like a lighter."
    license: MIT
    ---
    
    # Lighting, rendering and compositing
    
    Expert lighting controls where the eye goes. Shape comes from gradients, the subject is separated from its surroundings by value (checked desaturated), every light has a source and a purpose, and every decision is measured on the render, not on lamp values. Work from a controlled base, change one thing at a time, and compare. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes). Related: scenario-blender-texturing-shading (materials, skin SSS), scenario-blender-previs-storyboard (shot planning, cameras).
    
    Toolkit: [`scripts/bx_light.py`](scripts/bx_light.py) (tested on 5.2.1): rigs placed relative to subject and camera, EEVEE/Cycles presets, color setup and audit, 5.x compositor helpers, render analysis. Import: `sys.path.append("<this skill>/scripts"); import bx_light as BL`.
    
    ## Stance (the expert delta)
    
    - **Value separation first.** Andy Goralczyk separates character from background by value in almost every shot and checks it desaturated. Andrew Price: the highest-contrast area becomes the focal point whether you want it or not (the bright window beats the couch). Fix by darkening the set before brightening the subject.
    - **Light size and distance are the controls.** Andrew: a large source reads form and hides pores, a small one reads texture and grit, so a face key is a large source, never a small bulb (unless toon or deliberately harsh). Inverse square is an attention tool: move a light closer instead of adding helpers. No key:fill ratio is a rule (Andrew: "half the key is bogus"): dim the shadow side until it reads as shadow with detail, and measure on the subject.
    - **Motivate, then cheat plausibly.** Every light needs a source and a color from the world (Gleb Alexandrov, Lino Thomas). Andy puts the rim on the key side, because on the shadow side it reads as a bounce from nowhere. When the source is behind the subject he fakes a key into the face. Shadow casters and light linking keep cheats off the set. Gleb stops before the image "looks lit".
    - **EEVEE is a trench coat (Andrew).** Ray tracing is off in a factory scene. World light passes through walls unless the volume probe's outer samples sit at the inner walls (Andrew; measured 0.26 leak to 0.002; 5.2.1 puts samples one spacing inside the box, so size the box from the points). Soft lights need per-light jitter (Gleb) and custom distance (measured: automatic distance loses 8 %).
    - **Cycles economy (Andy, Sprite Fright):** 3 bounces, no caustics, no clamping, Fast GI, and bounces faked with lights. Keep sharp speculars for the eyes and hero metal only.
    - **Linear and wide until the end (Jacob Holiday).** EXR stays linear while PNG bakes the view transform. AgX hides clipping (linear 4 displays 0.91), so read the EXR. Global brightness is `exposure` in the view transform, not scaled lights (keeps every ratio, including world and emissives). AgX for scenes, Khronos PBR Neutral for product color. Set the working space once (Nathan Vegdahl).
    - **Rebalance in comp, not by re-rendering** (Wyatt, Robin). In Cycles, light groups per role (key, fill, rim, window): one render, then gains in the compositor (`BL.light_groups`, `BL.lightgroup_mix`). EEVEE has no light-group outputs: use light linking and view layers.
    - **Always comp, lightly (Pau Homs):** bloom, vignette, then a correction, stopping before it reads as a filter. In 5.x the compositor is a node group.
    - **Color has three contrasts (Lino):** value, saturation and hue. Keep one saturated focus hue and desaturate its complement over large areas.
    
    ## Establish first
    
    | Input                               | Why it changes the plan                                                                                                      | Default when silent                                        |
    | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
    | Still or animation                  | Continuity, flicker and temporal denoise                                                                                     | Still                                                      |
    | Engine                              | EEVEE: stylized, fast iteration, volumes, NPR, viewport comp with passes. Cycles: glass close-ups, bounce realism, reference | EEVEE to iterate, plus a Cycles reference render           |
    | Style                               | Toon terminators need a small, unjittered key                                                                                | Stylized-realistic                                         |
    | Subject and focus point             | Rig target (the face), mask for the analysis                                                                                 | Character head, else bbox center                           |
    | Time and motivation                 | Color scheme: warm practicals, cool sky, sun                                                                                 | Night/dusk interior: warm key (2,700 K [added]), cool fill |
    | Delivery                            | PNG display plus EXR master; external grade?                                                                                 | PNG + 32-bit multilayer EXR                                |
    | Color fidelity                      | Brand RGB: Khronos PBR Neutral; skin: ACES 2.0 or AgX High Contrast                                                          | AgX                                                        |
    | Late balance changes, comp hand-off | Cycles light groups per role, multilayer EXR                                                                                 | groups when rendering in Cycles                            |
    
    ## Workflow
    
    1. **Compose, then lock the camera.** Lighting is view dependent (Andrew). Use `BL.screen_xy` and `BL.aim_camera_at_frame_point(head, 1/3 or 2/3, 0.62)` for look room, and 80 mm for boxy products. GATE: one focal element; subject near a thirds point or a deliberate symmetry, pyramid or full frame.
    2. **Controlled base.** For a studio: world strength 0 and a near-black rough ground (Andrew). For an EEVEE interior: `pb = BL.volume_probe_for_room(inner_lo, inner_hi, cell=0.5, wall=0.2)` (outer samples on the inner walls), `BL.probe_report(pb, inner_lo, inner_hi)`, then `BL.bake_probes()`. For an exterior: `BL.outdoor_sun_sky(..., azimuth_space="world")`. GATE: `probe_report` flags nothing and a world-only render of a sealed interior is near black.
    3. **Key.** Use `BL.three_point`, `BL.motivated_interior` (window + practical, cheated linked key, spot practical, rims, bounce) or `BL.outdoor_sun_sky`. Lights stay out of walls automatically. GATE: `BL.light_report(target=head, face=head)` has no flags (frontal key, key below the eye line, key smaller than the head), and a clay render (`BL.clay(True)`) gives `internal_ratio >= 2.5`.
    4. **Supporting lights.** Add fill, rim, bounce and `BL.eye_light`, plus `BL.shadow_caster`, `BL.fake_sun`, `BL.split_world` or `BL.haze` as needed. Linking lives on the light OBJECT (`obj.light_linking`, via `BL.link_light`), not the Light datablock. Adjust one lamp at a time: `BL.light_contributions(lights, mask, dir)`, or in Cycles `BL.lightgroup_check(exr, mask)` from one render. GATE: the key has the largest share on the subject, the world is under 10 % indoors, and each rim is motivated.
    5. **Measure and look.** `r = BL.render(base)` gives PNG + EXR. Then `mask = BL.mask_cryptomatte(r["exr"], names)` (or `BL.mask_workbench`), `rep = BL.analyse(r["png"], mask, exr=r["exr"])`, `BL.verdict(rep)` and `BL.value_study(...)`, and open the images. GATE: the verdict is empty and in the value study the subject owns a value group; otherwise darken the set, carve with spots or blockers, and link the key.
    6. **Color.** `BL.color_setup(view, look, exposure)` (brightness here, never by scaling lamps) and `BL.color_audit()`. GATE: the audit is empty, no complementary clash is flagged, clip is at most 0.5 % outside emitters, and the linear max is intended.
    7. **Render settings.** `BL.preset_eevee(quality="final", interior=...)` or `BL.preset_cycles(quality="final", budget="film")`. For EEVEE finals, render a Cycles preview reference (Andrew). GATE: the subject is within about 30 % of Cycles and there are no engine artifacts (critique section 7).
    8. **Comp.** Cycles: `mix = BL.lightgroup_mix(gains, exr=r["exr"])` re-grades key/fill/rim/window from the EXR in about 0.2 s (`BL.set_gain`, RGB gains recolor). Then `BL.comp_finish(bloom=0.25, vignette=0.3, contrast=0.05, source=mix["socket"])`; masks with `BL.matte(tree, names)`; pixel sizes through `BL.relative_px`; passes, back to beauty and view layers are in procedures P13. GATE: re-run the verdict on the finished frame; the vignette ratio should fall.
    9. **Deliver with evidence:** final PNG, EXR, value study, the light table, the verdict, and what was not verified.
    
    **Animation:** pin the sun per location with `sun_azimuth` within about 30 deg (Andy). Light the wide shot and propagate it to coverage. Check shots with `BL.contact_sheet` and `BL.sheet_stats` (outliers more than 0.5 stop from the median). Keep temporal denoise on and never use ray-tracing resolution 1/16.
    
    ## Numbers
    
    | Value                                                                                                                                                                                     | Relative to     | Source                 |
    | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ---------------------- |
    | Level of a white card: point P/(4π²d²), area P/(π²d²), sun S/π                                                                                                                            | `BL.energy_for` | measured, both engines |
    | Source as big as its distance delivers 75 % of the nominal level                                                                                                                          | apparent size   | measured               |
    | AgX display: 0.18 to 0.463, 1.0 to 0.773, 4.0 to 0.910                                                                                                                                    | linear value    | measured               |
    | Key 40 deg off axis, 35 deg up, 3 subject radii, size = radius                                                                                                                            | subject, camera | [added] defaults       |
    | Frontal flag: key < 20 deg off the camera axis                                                                                                                                            | camera axis     | [added]                |
    | Sun angle about 2 deg; continuity ±30 deg                                                                                                                                                 | set             | Andy                   |
    | Kelvin: candle 1,500, incandescent 2,000 to 2,500, daylight 6,500 (practical default 2,700 [added])                                                                                       | light           | Andrew                 |
    | EEVEE: RT 1:1 and GI 1:2 for finals, Fast GI steps 16, shadow steps 12 to 16 (leaks), shadow rays 2 (penumbra)                                                                            | render          | Andrew                 |
    | Volume probe: outer samples on the inner walls (samples at local (i+1)/(N+1)x2-1); leak 0.22 at 0.44 m inside, 0.054 at 0.2 m, 0.002 at the wall, 0.009 past the wall; surfel density 100 | room            | Andrew, measured       |
    | Brand color error (of 255): Khronos 3.8, AgX 27 to 42, Standard 33                                                                                                                        | white furnace   | measured               |
    | Haze density about 0.05 for a room                                                                                                                                                        | volume          | Andrew                 |
    | Cycles film: 3 bounces, clamp 0, no caustics, 100 test / 500 to 1,500 final samples                                                                                                       | render          | Andy                   |
    | EXR: multilayer, half float, DWAA for comp; 32-bit masters                                                                                                                                | output          | Robin, Andy            |
    | Verdict: surround separation ≥ 0.10, edge merge ≤ 35 %, clay form ratio ≥ 2.5, value range ≥ 0.30, clip ≤ 0.5 %                                                                           | display luma    | [added], calibrated    |
    
    ## Quality gates
    
    Measurable:
    
    ```python
    rep = BL.analyse(png, mask, exr=exr); problems = BL.verdict(rep)          # [] = pass
    flags = BL.light_report(target=head, face=head)["flags"]; audit = BL.color_audit()
    probe = BL.probe_report(pb, inner_lo, inner_hi)["flags"]                  # EEVEE interiors: []
    groups = BL.lightgroup_check(exr, mask)                                   # Cycles: rel_error ~0, shares
    shares = BL.light_contributions(lights, mask, out_dir)                   # per-light share on subject
    ```
    
    Visual: open the render, the `value_study` panel (desaturated | value groups | squint) and, for sequences, the contact sheet. Judge them with [`references/critique.md`](references/critique.md): first read, value structure, form, motivation, color, composition, artifacts, finish.
    
    ## Common mistakes
    
    | Mistake                                                         | What it looks like                             | Fix                                                                       |
    | --------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
    | Frontal key                                                     | Flat, "flash photo", clay form ratio about 1.3 | Key 30 to 60 deg off axis, above the eye line                             |
    | Practical behind the subject used as the key                    | Dull face, bright wall steals focus            | Cheated key on the source's side, linked to the subject                   |
    | Bare point practical near a wall                                | Blown wall, shelf becomes the focal point      | Spot with the shade's cone; blocker                                       |
    | EEVEE interior without probe, or a probe box drawn to the walls | Shadows lifted blue/gray everywhere, muddy     | Outer samples at the inner walls (`probe_report`), bake, sealed-room test |
    | Small bulb as a face key                                        | Pores and texture, harsh face                  | Large source (head-sized or more); flag in `light_report(face=)`          |
    | Brightening by scaling lamps                                    | World and emissives left behind, ratios drift  | `exposure` in the view transform                                          |
    | `light.data.light_linking`                                      | AttributeError                                 | Linking and light groups are Object properties                            |
    | Re-rendering to rebalance lights                                | Hours per tweak                                | Cycles light groups + `lightgroup_mix`                                    |
    | Rig lights inside walls                                         | A rim that contributes nothing                 | `avoid=subject` (default in rigs)                                         |
    | Unlinked faked key                                              | Set brightens with the subject, no separation  | `link_key=True`; key share high, background share near 0                  |
    | Judging clipping on AgX PNG                                     | Highlights look fine, EXR at 20+               | Read `linear_max`, use exposure                                           |
    | Big soft jittered key on a toon material                        | Dithered, noisy terminator                     | Small unjittered key for NPR (Wyatt: crisp shadows for toon)              |
    | Inverted hull outline                                           | Character goes dark or flat                    | Outline material transparent to shadow rays (`outline_hull`)              |
    | AOV named in shader only                                        | Pass silently missing                          | `BL.register_aovs()`                                                      |
    | Pixel-sized comp effects                                        | Look changes between preview and final         | `BL.relative_px`                                                          |
    | Exclude instead of indirect-only in layers                      | Missing shadows in the comp                    | `indirect_only` (0.0004 vs 0.016 diff)                                    |
    
    ## Blender 5.2 notes
    
    - EEVEE id is `BLENDER_EEVEE`; `use_raytracing` is False by default; `use_bloom/ssr/gtao/soft_shadows` are gone (Glare node, ray tracing, Fast GI, per-light jitter).
    - Probes are `SPHERE/PLANE/VOLUME`; `lightprobe_cache_bake` runs synchronously headless; the sphere-probe default is 512. Volume samples sit one spacing inside the box faces, never on them.
    - EEVEE writes Cryptomatte but no Object Index pass; light groups are Cycles only (EEVEE Render Layers has no `Combined_<group>`); light linking works in both engines. `light_linking` and `lightgroup` are Object properties; `bpy.types.Light` has neither.
    - Compositor: `scene.compositing_node_group`, Group Output, `ShaderNodeMix` (color sockets 6/7, result 2), menu inputs take display names ('Bloom'). GPU is the default device. Assets live in `datafiles/assets/nodes/compositing_nodes_essentials.blend`.
    - Color: the working space is read-only (use `bpy.ops.wm.set_working_color_space`); looks are prefixed ('AgX - Punchy'); 'Linear'/'Raw' are rejected; camera log spaces exist; Principled Thin Wall exists for window glass.
    - `matrix_world` is stale after setting a transform; call `view_layer.update()`. OIIO `read_image(subimage, ...)`: the first argument selects the part.
    
    ## References
    
    - [`references/procedures.md`](references/procedures.md): tested code for every stage (P1 units to P17 exposure and color), with measured results and test paths. Load before writing code.
    - `references/critique.md`: the rubric to judge a render or sequence, in expert reading order, with the code check behind each question.
    - [`references/expert-notes.md`](references/expert-notes.md): principles per expert with timestamps, plus the disagreements table. Load for judgment calls and attribution.
    - [`references/sources.md`](references/sources.md): the 15 videos, credentials, best timestamps, and which to consult for which question.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related