3d Wood Lighting Scorecard
Meng ToCommunityCreated by Meng To
- Skill
- Review and audit
- Software development
- MIT
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured approach to lighting carved wood scenes in Three.js, focusing on achieving realistic material and volumetric effects. It includes a headless scorecard system that evaluates renders against a specific rubric, measuring metrics like grain contrast, shadow contact, and volumetric shaft quality. By running the provided scorecard, developers can objectively assess their scene's visual fidelity, identify specific areas for improvement, and iterate based on quantitative data rather than subjective mood.
What it does
- Provides a rubric for scoring wood material and lighting quality from 0 to 10.
- Includes a headless scorecard tool to automate visual quality assessment.
- Offers specific implementation rules for Three.js lighting, shadows, and procedural wood materials.
- Enables ablation studies to measure the impact of individual layers on the final render.
- Provides guidance on optimizing performance for volumetric effects and shadow maps.
When to use it
- When you need to achieve high-fidelity wood rendering in a dark, lit interior scene.
- When you want to move from subjective visual judgment to objective, metric-driven quality control.
- When your wood textures appear flat, plastic, or washed out.
- When you need to debug lighting issues like shadow acne, peter-panning, or volumetric light washing out blacks.
Advantages
- Removes subjectivity from the rendering review process.
- Provides clear, actionable metrics for improving specific visual elements.
- Allows for rapid iteration through automated headless testing.
- Helps identify performance bottlenecks in complex lighting pipelines.
Usage examples
- “Score this wood render out of 10.”
- The agent runs the headless scorecard, evaluates the scene against the rubric, and returns a score for each of the eight categories with suggestions for improvement.
- “Get the lighting quality to 8.”
- The agent analyzes the current render metrics, identifies the lowest-scoring sub-metrics, and provides specific adjustments to lighting or material parameters to reach the target.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/mengto/3d-wood-lighting-scorecard/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip 3d-wood-lighting-scorecard.zip -d .agents/skills
Documentation by Meng To
3D Wood Lighting Scorecard
Wood only looks like wood under one directional key raking across a dark room, so the grain, the shadow seam and the light shafts are lit by the same window, tone-mapped once at the end, and each is measured on the finished frame instead of judged by mood.
Verified stack: Three.js r169 (unpkg), WebGL2, GLSL patched into MeshPhysicalMaterial.onBeforeCompile, a half-float MSAA target and a hand-written ACES pass. The demo is demo.html; the numbers are references/.
Boundaries. This is the lighting-and-material layer for one lit interior. For sun shafts that must be cut by real trees and rooflines use 3d-sky-rays (screen-space occlusion); here the shafts are authored planes. For scanned or painted PBR maps, texel budgets and delivery use 3d-high-resolution-textures; here the wood is procedural from one shared 1024 noise field. For a bright rim on dark metal use 3d-specular-rim-glow; for glass use the studio-box ladder, not this. For the scoring habit alone use workflow-score-to-target; this skill supplies the wood rubric and the machine that measures it.
Extracted from Kibori (木彫), a scroll-driven WebGL woodworking page: its woodMat, buildEnvironment, key rig, shaft planes, dust and final pass are ported, with the window moved to where the shafts need it.
Build order
Renderer:
NoToneMapping,LinearSRGBColorSpace,HalfFloatTypetarget withsamples: 4,autoClear = false. Draw everything, additive shafts and dust included, into that target. Tone-map once in a last full-screen pass.One window. Put the emissive paper, the key light's direction, the shaft planes, and the window card in the PMREM environment on the same line. Nothing else.
Key
DirectionalLight0xfff2df, intensity 2.0, 4096 shadow map (2048 under 820 px),normalBias0.0045 (0.0085 at 2048),bias-0.00025,shadowMap.autoUpdate = falsefor a static room. Let a real wall with a real opening cast: the pool of light on the bench is then the window's shape.Fill 0xaec7df 0.60-0.76 from the camera side, rim 0xffdeb4 0.72 from behind, hemisphere 0xb5c5d7 / 0x35281c 0.19. These only keep the shaded flank alive; they are not the look.
Wood:
MeshPhysicalMaterialior 1.43,specularIntensity 0.72, roughnessclamp(base * W.rough, 0.35, 1), procedural albedo/roughness/bump from one periodic noise field baked once (1024x1024 RGBA8, 64 cells, three octaves, mipmapped, repeat).Shafts, then dust, then the last pass, then run
references/scorecard.mjs.
Rules, each with the failure it prevents
Grain vanishes under flat light. A hemisphere or environment that is bright enough to see the room fills every pore and the board reads as painted. Keep the key at 2.0 and the ambient terms low; measured here, turning the studio reflections off raised the total score by 0.14 because they lifted the shadows. Per-material envMapIntensity 0.25 for the bench, 0.45 for the carved board, 0.40 default.
envMapIntensity does nothing and tuning "works" on paper. With three r163+ the material value is only read when material.envMap is set; scene.environment alone ignores it. Set mat.envMap = env on every wood material, and toggle a layer through the intensity, not by nulling the map (that recompiles).
The sheen mirrors a window that is not there. Kibori hung its environment window at front-left; a scene whose window is behind-left reflects a light that does not exist. Place the environment window pane group where the real window is (here (-2.3, 4.3, -3.2), scale 0.5, 12 panes 1.24x1.38 at radiance 3.8/3.55/3.05). Tilt the board so the mirror direction lands a little off the window (0.27 rad here; at exactly the mirror angle the whole board turns milky).
Volumetrics wash the blacks and the grain. Additive planes add light to everything behind them, including the top of the timber they cross. Fade each shaft by world height (smoothstep(0.02, 0.34, y)), keep the gain at 0.13 (Kibori 0.12) and the brightness above the reference strip under 35 levels, and read the black point: p1 must stay under 7. Before the height fade, gain 0.14 laid a milky haze across the plaque and the grain went with it.
A hard wedge where a shaft plane cuts a solid. The plane intersects the bench or the post and draws a bright line. Kibori kept planes behind the benches; the height fade above does it without moving them. Also clamp before pow: pow(1.0 - across, 2.2) with a hair-negative edge coordinate is NaN and NaN through additive blending is a black wedge.
God rays clip. If the renderer tone-maps, or the shaft pass is added after ACES, the beams either double-map or hit 1.0 flat. Sum them in linear HDR, then run ACES once (a=2.51 b=0.03 c=2.43 d=0.59 e=0.14). The window paper is toneMapped:false linear (1.15, 0.86, 0.52); at (3.9, 3.35, 2.55) it clipped to white paper.
Additive alpha. AdditiveBlending adds alpha too. On an opaque canvas it is invisible; if the scene is ever drawn into an alpha-composited layer, use CustomBlending with blendSrc One, blendDst One, blendSrcAlpha Zero, blendDstAlpha One.
Shadow acne on the bench, or a gap under the plaque. Acne comes from a small negative bias alone; use normalBias scaled to the map (0.0045 at 4096, 0.0085 at 2048). A gap between object and shadow (peter-panning) is normalBias too high. The scorecard reads it as contactGap. Add a baked contact pool under each object (radial alpha 0.78, r = 0.30..1.0 smoothstep, y 0.0012); the shadow map alone leaves the seam light.
A single-band texture shimmers at grazing angles. Fine bands (pores at 96/88 cycles per metre, fibres 230, rays 13/115) must fade with fwidth of their own coordinate, and the bump must give up before the colour does (1 - smoothstep(0.05, 0.18, vesselPx)), or dFdx(h) beats into moire. The scorecard's shimmer is a half-pixel view shift; 1.1 here, 3+ is aliasing.
A carved relief turns to noise. Grooves under 12 px per period alias in the bump and sparkle against a bright environment. The seigaiha carving here uses 0.15 m cells and two rings per circle (about 16 px at 1440 wide); at 0.062 m it was noise at 1x and fine at 2x. Size carving to the pixel budget, not the model.
Bricks in the grain. Medullary-ray flecks (rays) on a flat-sawn top face read as a tiled grid. Kibori's 0.8 default is for quartersawn faces; on the plaque it is 0, on others 0.15.
Banding in the dark wall. 8-bit steps in a vignetted gradient. A +-0.0015 hash dither ((hash - 0.5) * 0.003) after the sRGB encode removes it; the scorecard counts runs of 6+ identical pixels (flatRun 0.019).
The metric moves when the camera does. Freeze the sway and time (KiboriScore.freeze(true)), render and read the canvas in the same task (no preserveDrawingBuffer), capture at a fixed size and DPR, and use headless Chromium; the in-app pane backgrounds itself and shares the GPU.
Constants
| Item | Value |
|---|---|
| Camera | 34 deg vertical, (0.60, 1.25, 2.55) looking at (0.05, 0.50, -0.30); wider FOV below aspect 1 |
| Light direction | (0.40, -0.75, 0.45) normalised, window centre (-0.60, 1.40, -1.0), opening 1.5 x 1.5 |
| Target | HalfFloat, MSAA 4, DPR cap 2 |
| Key / fill / rim / hemi | 2.0 / 0.60 / 0.72 / 0.19 (Kibori fill 0.76) |
| Shafts | 5 planes 0.44-0.52 wide, length 2.75, gain 0.13, foot fade 0.22 (Kibori 0.30), fbm 3 octaves |
| Wall rays | 10 planes, 0.20-0.90 wide, length 2.2, gain 0.08 |
| Dust | 1900 points, size 17 / depth px clamped 3.4-46, 1 - smoothstep bokeh, gated to within ~0.33 m of a beam axis |
| Field | 1024^2 RGBA8, 64 periodic cells (n, f, second n), mipmapped; 16 px for the "low" toggle |
| Last pass | unsharp 0.10, vignette 0.42 + 0.58 v, ACES, sRGB, dither 0.003 |
Cost (measured, headless Chromium on Apple Silicon, Metal)
Full pipeline, one frame plus a 1 px readback: 7.4-7.9 ms at 1440x900 x1, 16.5 ms at 2880x1800 (DPR 2), 7.2 ms at 390x844 DPR 2. The shaft and wall-ray planes are the dearest layer (a 3-octave fbm per additive pixel: about 1 ms at x1, 5 ms at x2); the shadow map is free after the first frame because it is static; grain, env, key and dust are each about 0.2-0.7 ms. The cheap lever is the fbm octave count on the shafts (or drawing them at half resolution), not fewer planes. Do not conclude from one toggle: at 2x the noise between runs is about 1 ms.
Scoring loop
Serve the page,
npm i playwright-core, runnode references/scorecard.mjs(--url,--size 1440x900,--dpr 1,--target 8,--no-ablate,--sync). It captures headless, freezes time, scores the eight items inreferences/rubric.md, ablates each layer and writes JSON; exit code 1 if any item is short.Score the baseline before changing anything (all layers off: 3.7 here) and keep the file. The ablation is the check on the metrics: a layer whose removal raises the total is either measured wrongly or not earning its cost.
Fix the lowest item's cheapest sub-metric first, re-run at the same size and DPR, and keep only what the picture confirms. Look at a 2x crop after every metric-driven change; three of this demo's "improvements" raised a number and made the board look worse (full-board clearcoat, anisotropy 0.3, per-pixel tone gain 2.4) and were reverted.
For a score that means "looks right", hand the captures and the rubric to a fresh judge that has not seen the numbers, and take the lower of the two.
Report honestly. Demo, 1440x900 x1: 8.8 average; grain 8.1, key/fill 10, shadows 9.6, shafts 8.0, rays 8.8, tone 9.8, texture 9.7, specular 6.5 (short of 8: the sheen is broad, not a distinct highlight, and the fixes that added one damaged the grain). At DPR 2 the specular item is 7.4 and shafts 7.3 (dust specks finer than the measuring window).
Accessibility and lifecycle
prefers-reduced-motion: a designed still (time fixed at 5.2, no sway, no dust drift), rendered on demand; the layer switches and the score still work.Pause on
document.hiddenand when the canvas leaves the viewport;dtclamped to 1/30 s, and the first frame after a pause hasdt = 0.Size from a
ResizeObserver; DPR capped at 2; target and dust point size follow it.Layer switches are real checkboxes with visible focus; the score is an
aria-liveregion.Below 820 px the panel moves under the canvas and a score chip sits on it.
Verify
The metrics block in
demo.htmlequalsreferences/metrics.js(node scorecard.mjs --sync).1440x900 and 390x844, console clean, reduced motion shows a composed frame, tab order reaches every switch.
A scorecard JSON with baseline, all-on, ablation and cost exists and matches the claims above.
Files
- ATTRIBUTION.mdAdded by AutoGineer866 B
- LICENSELicence file added by AutoGineer1.0 KiB
- PROMPT.md5.5 KiB
- SKILL.md11.1 KiB
- agents/openai.yaml378 B
- demo.html64.1 KiB
- preview.jpg143.6 KiB
- references/last-scorecard.json11.4 KiB
- references/metrics.jsScript16.2 KiB
- references/rubric.md7.6 KiB
- references/scorecard.mjsScript9.5 KiB
references/rubric.md
Rubric: lit wood in a dark room, scored out of 10
Eight items, each 0-10, each built from 2-4 measured sub-metrics. A sub-metric maps to a score through three anchors, [v@3, v@6, v@9], linear between them and beyond, clamped to 0-10. An item's score is the mean of its sub-scores. The anchors below are the numbers in metrics.js; if you change one, change it in both places and re-run the baseline and the ablation, or the scores stop meaning anything.
Benchmark for "9": a still from a Kibori station (oiled hinoki on a bench, one window, dust in the beam) that you could not tell from a photograph at a glance. "6" is a competent three.js scene: right colours, lit, nothing wrong, nothing convincing. "3" is a flat-shaded prop.
Scale: 10 study it, 9 a trained eye finds almost nothing, 8 polished with minor nits, 7 a noticeable flaw, 6 works with visible rough edges, 5 mediocre, 3-4 clearly wrong in places, 1-2 barely works.
Every region below is in the frame's own fractions and is projected from world points through the live camera (KiboriScore.regions() in the demo). Scoring a different scene means writing that function, not changing the metrics.
1. Materials and grain
| looks like | ||
|---|---|---|
| 3 | one flat tan colour, or a tiled photo with a visible repeat; grain lines cross the board at random angles | uFlat on: 1.7 |
| 6 | streaks run the right way but every board has the same pattern; no pores, no ring drift | |
| 9 | rings drift into cathedral arcs, pores and fibres resolve at 1 px, end grain reads as cells, no two boards alike | demo: 8.1 |
Measure, on a lit patch of plain wood (not the carving):
grainContrastband-pass 1-8 px std / mean luma: 0.006 / 0.022 / 0.05grainAnisogradient energy across the grain over along it: 1.1 / 1.9 / 3.2 (lines that follow the axis)toneVarstd of a 29 px blur / mean, the broad drift of ring bands: 0.008 / 0.035 / 0.08
2. Key / fill contrast
| looks like | ||
|---|---|---|
| 3 | lit and shaded faces within half a stop; reads as ambient light | baseline 4.1 |
| 6 | a clear light side, but the dark side is either mud or black | |
| 9 | warm key 3+ stops over a cool fill that keeps the shaded flank alive; the room stays dark around the pool of light | demo: 10 |
keyFillStopslog2(lit face / shaded face), linear light: 0.7 / 1.7 / 2.8rangeStopsp99 / p1 of the whole frame, stops: 3 / 5 / 7shadeAliveshaded flank in linear light x1000 (must not crush to 0): 1.5 / 4 / 10
3. Shadows and contact
| looks like | ||
|---|---|---|
| 3 | no shadow, or a shadow with a bright gap under the object (peter-panning) | baseline 5.6 |
| 6 | shadows land but are uniform grey; the base of each object floats a little | |
| 9 | shadow is deep where it starts, opens into a soft penumbra, and the object sits in a dark seam where it touches | demo: 9.6 |
shadowRatiolit bench / shadowed bench, linear: 1.6 / 3.5 / 7contactDarkcontact strip / open bench, lower is better: 0.75 / 0.42 / 0.18penumbraMonoshare of the profile that rises monotonically from contact: 0.6 / 0.8 / 0.95contactGapbrightness drop moving away from contact, i.e. a light sliver, lower is better: 0.30 / 0.10 / 0.02
4. Volumetric shafts and dust
| looks like | ||
|---|---|---|
| 3 | no beam, or a hard-edged translucent plane with a visible end | baseline 4.7 |
| 6 | a beam with soft sides but the same brightness end to end; dust absent or evenly sprinkled | |
| 9 | modulated beams that fade in and out along their length, a hard wedge nowhere, blacks intact beside them, dust that shows only where the light is | demo: 8.0 |
shaftDeltalevels above a reference strip out of the beam, 1.2 / 4 / 9, and it must not exceed 35 (a beam that bright is washing the room; the score falls to 3 at 70)shaftRoughjaggedness of the falloff, lower is better: 0.5 / 0.22 / 0.08dustFracsoft specks 6+ levels above their 7 px neighbourhood, in the beam minus dark wall: 0.0002 / 0.001 / 0.003
5. Background rays
| looks like | ||
|---|---|---|
| 3 | none, or one band | baseline 0.8 |
| 6 | two bands, even brightness | |
| 9 | four or more bands of unequal width and brightness fading down the wall, behind the objects, not competing with them | demo: 8.8 |
rayContrastp92 - p8 across the wall band, levels: 1 / 3 / 6.5, capped: above 45 it is glare (3 at 90)rayPeaksdistinct bands across the wall: 1 / 2 / 4
6. Colour and tone mapping
| looks like | ||
|---|---|---|
| 3 | highlights clipped to white, blacks lifted to grey, banded gradients, warm and cool the same | baseline 4.5 |
| 6 | no clipping but flat, or crushed | |
| 9 | the window rolls off instead of clipping, black point at 2-7, warm key vs cool shadow, no bands in the dark wall | demo: 9.8 |
clipPct% of pixels with a channel >= 254, lower better: 4 / 1.2 / 0.25p1black point luma, lower better until crushed: 34 / 18 / 7crushPct% of pixels < 2: 45 / 22 / 8warmCoolR-B of the same bench in light minus in shadow: 1 / 8 / 18flatRunshare of wall pixels in runs of 6+ identical values (8-bit banding), lower better: 0.5 / 0.2 / 0.04
7. Texture resolution and texel density
| looks like | ||
|---|---|---|
| 3 | soft mush at the closest view, or shimmering moire on a board seen edge-on | field 16 px: 6.9 |
| 6 | sharp face-on, crawls at grazing angles | |
| 9 | 1 px detail face-on, calm when the camera moves by half a pixel, source texels >= 1 per device pixel | demo: 9.7 |
fineStd1 px Laplacian std, levels: 0.5 / 1.4 / 3.2texelPerPxsource texels per device pixel at the finest grain band, below 1 means magnified (soft): 0.45 / 0.9 / 1.6shimmershift the view by half a pixel; mean |A-B| over 0.5 x mean |gradient|, lower better: 3.2 / 1.9 / 1.15. A prefiltered surface is ~1; an aliased one is 3+.
8. Specular and roughness realism
| looks like | ||
|---|---|---|
| 3 | no highlight, or a white plastic hotspot | baseline 4.5 |
| 6 | a smooth sheen that ignores the grain | |
| 9 | a broad soft sheen that travels across the board, broken up where open pores raise the roughness, never near-white | demo: 6.5 (short) |
sheenSpreadp95 / p25 of the blurred wood, linear: 1.06 / 1.3 / 1.9paintClipshare of near-white pixels in the wood, lower better: 0.05 / 0.01 / 0.0005hlBreakuptexture surviving inside the brightest quartile of the sheen: 0.004 / 0.018 / 0.045
Reading a scorecard honestly
These are proxies. Two things a metric cannot see: whether the object reads as the thing it is (a plaque, not a tray), and whether a lighting choice is tasteful. For those, hand the captures to a fresh judge who has not seen the numbers and take the lower of the two scores.
The ablation in the scorecard is the check on the metrics themselves. A layer that does not move any item it should move, or whose removal raises the total, means the metric is measuring the wrong thing or the layer is not earning its cost. In the demo, turning the studio reflections off raised the total by 0.14 (they lift the shadows); that is reported, not tuned away.
A score is only comparable at the same size and DPR. The demo scores 8.8 at 1440x900 x1 and 8.7 at x2 (more texels, but the dust specks are finer than the 7 px window).
Stop when every item reaches the target or one plateaus for a reason outside the task. The demo's specular item plateaus at 6.5-7.4: the sheen is broad, not a distinct highlight, and the two levers that add one (clearcoat over a window that fills the board, and anisotropy) veiled the grain or smeared the carving in a 2x crop, so they were rejected on sight.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Meng To
- Publisher
- Meng To (Community)
- Source
- MengTo/Skills · agent-skills/3d/3d-wood-lighting-scorecard · commit 5ea1661
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Meng To or Meng To.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download 3d-wood-lighting-scorecard.zip