3d-wood-lighting-scorecard

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

3D Wood Lighting Scorecard

3D 木材光照评分卡

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.
只有在暗室中以一束方向性主光斜掠照射时,木材才像木材;因此木纹、阴影接缝和光束都由同一扇窗照亮,在最后统一进行一次 tone mapping,并在最终帧上逐项测量,而不是凭感觉判断。
已验证技术栈:Three.js r169(unpkg)、WebGL2、注入
MeshPhysicalMaterial.onBeforeCompile
的 GLSL、half-float MSAA target,以及手写的 ACES pass。Demo 是
demo.html
;测量数据位于
references/
。
边界。这是一套面向单个室内受光场景的光照与材质层。对于必须被真实树木和屋顶轮廓遮挡的太阳光束,使用
3d-sky-rays
(screen-space occlusion);这里的光束是手工编排的平面。对于扫描或绘制的 PBR 贴图、texel 预算和交付,使用
3d-high-resolution-textures
;这里的木材则由一个共享的 1024 噪声场程序化生成。对于暗色金属上的明亮轮廓光,使用
3d-specular-rim-glow
;玻璃应使用 studio-box ladder,而不是本方案。若只需要“评分至目标”的工作习惯,使用
workflow-score-to-target
;本 skill 提供木材评分规则和用于测量的机制。
提取自 Kibori(木彫)——一个滚动驱动的 WebGL 木工页面:移植了其中的
woodMat
、
buildEnvironment
、主光装置、光束平面、尘埃和最后 pass,并将窗户移动到光束所需的位置。

Build order

构建顺序

  1. Renderer:
    NoToneMapping
    ,
    LinearSRGBColorSpace
    ,
    HalfFloatType
    target with
    samples: 4
    ,
    autoClear = false
    . Draw everything, additive shafts and dust included, into that target. Tone-map once in a last full-screen pass.
  2. 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.
  3. Key
    DirectionalLight
    0xfff2df, intensity 2.0, 4096 shadow map (2048 under 820 px),
    normalBias
    0.0045 (0.0085 at 2048),
    bias
    -0.00025,
    shadowMap.autoUpdate = false
    for 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.
  4. 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.
  5. Wood:
    MeshPhysicalMaterial
    ior 1.43
    ,
    specularIntensity 0.72
    , roughness
    clamp(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).
  6. Shafts, then dust, then the last pass, then run
    references/scorecard.mjs
    .
  1. 渲染器:
    NoToneMapping
    、
    LinearSRGBColorSpace
    、
    HalfFloatType
    target,并设置
    samples: 4
    、
    autoClear = false
    。将所有内容绘制到该 target 中,包括加法混合的光束和尘埃。最后通过一个全屏 pass 完成一次 tone mapping。
  2. 一扇窗。将自发光纸张、主光方向、光束平面和窗户卡片置于 PMREM environment 中的同一条线上。除此之外不再添加其他元素。
  3. 主光
    DirectionalLight
    0xfff2df,强度 2.0,4096 shadow map(低于 820 px 时使用 2048),
    normalBias
    0.0045(2048 时为 0.0085),
    bias
    -0.00025;对于静态房间,设置
    shadowMap.autoUpdate = false
    。让带有真实开口的实体墙负责投射阴影:这样工作台上的光池就会呈现窗户的形状。
  4. 补光 0xaec7df,强度 0.60-0.76,来自相机一侧;轮廓光 0xffdeb4,强度 0.72,来自后方;半球光 0xb5c5d7 / 0x35281c,强度 0.19。这些光仅用于避免暗面完全死黑;最终效果并不依赖它们。
  5. 木材:
    MeshPhysicalMaterial
    ,
    ior 1.43
    ,
    specularIntensity 0.72
    ,roughness 为
    clamp(base * W.rough, 0.35, 1)
    ;程序化 albedo/roughness/bump 均来自同一个周期性噪声场,只烘焙一次(1024x1024 RGBA8、64 个 cells、3 个 octaves、带 mipmap、可 repeat)。
  6. 依次添加光束、尘埃和最后 pass,然后运行
    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.
木纹在平淡光照下消失。 亮度足以看清整个房间的 hemisphere 或 environment 会填满每个木孔,使木板看起来像刷过漆。保持主光为 2.0,并压低环境光项;这里的实测结果显示,关闭影棚反射反而让总分提高了 0.14,因为它抬高了阴影。逐材质的
envMapIntensity
设置为:工作台 0.25、雕刻板 0.45,默认值 0.40。
envMapIntensity
不起作用,而调参在纸面上却“有效”。
在 three r163+ 中,只有设置了
material.envMap
才会读取材质值;仅设置
scene.environment
会忽略它。在每个木材材质上设置
mat.envMap = env
,并通过 intensity 切换图层,而不是将 map 设为 null(那会触发重新编译)。
光泽映出了根本不存在的窗户。 Kibori 将 environment 窗户挂在左前方;对于窗户位于左后方的场景,它会反射一束并不存在的光。将 environment window pane group 放到真实窗户所在位置(这里为
(-2.3, 4.3, -3.2)
,scale 0.5,12 个窗格,每格 1.24x1.38,radiance 为 3.8/3.55/3.05)。倾斜板材,使镜面反射方向稍微偏离窗户(这里为 0.27 rad;恰好处于镜像角度时,整块板会变得乳白)。
体积光冲淡黑位和木纹。 加法混合平面会给其后方的所有内容增加光量,包括光束穿过的木材顶部。按世界高度淡化每道光束(
smoothstep(0.02, 0.34, y)
),将 gain 保持为 0.13(Kibori 为 0.12),并使参考色条上方的亮度低于 35 levels;同时检查黑位:
p1
必须保持在 7 以下。应用高度淡化之前,gain 0.14 会在牌匾上铺开一层乳白色薄雾,连木纹也一起消失。
光束平面切入实体处形成硬楔形亮边。 平面与工作台或立柱相交时会画出亮线。Kibori 将平面放在工作台后方;前述高度淡出无需移动它们即可解决此问题。此外,在
pow
之前调用
clamp
:
pow(1.0 - across, 2.2)
中只要边缘坐标略微为负,结果就是 NaN,而 NaN 在加法混合中会形成黑色楔形。
God rays 发生裁切。 如果渲染器执行 tone mapping,或在 ACES 之后叠加 shaft pass,光束要么被映射两次,要么直接被压平到 1.0。先在线性 HDR 中求和,然后只运行一次 ACES(
a=2.51 b=0.03 c=2.43 d=0.59 e=0.14
)。窗纸采用
toneMapped:false
的线性值(1.15, 0.86, 0.52);在 (3.9, 3.35, 2.55) 处它会裁切成纯白纸张。
加法混合也会影响 alpha。
AdditiveBlending
同样会累加 alpha。在不透明 canvas 上这不可见;如果场景以后要绘制到采用 alpha 合成的图层中,请使用
CustomBlending
,参数为
blendSrc One, blendDst One, blendSrcAlpha Zero, blendDstAlpha One
。
工作台出现 shadow acne,或牌匾下方出现空隙。 仅使用较小的负
bias
会产生 acne;应使用按阴影贴图分辨率缩放的
normalBias
(4096 时为 0.0045,2048 时为 0.0085)。物体与阴影之间的空隙(peter-panning)也是
normalBias
过大导致的。Scorecard 会将其读取为
contactGap
。在每个物体下方添加烘焙的接触光池(径向 alpha 0.78,r = 0.30..1.0 smoothstep,y 0.0012);仅靠 shadow map 会留下亮缝。
单频纹理在掠射角下闪烁。 细纹带(每米 96/88 周期的木孔、230 周期的纤维、13/115 周期的 rays)必须依据其自身坐标的
fwidth
淡出,并且 bump 必须比颜色更早放弃细节(
1 - smoothstep(0.05, 0.18, vesselPx)
),否则
dFdx(h)
会在 bump 中形成摩尔纹。Scorecard 的
shimmer
指标以半像素的视角偏移测量;这里为 1.1,而 3 以上就属于 aliasing。
雕刻浮变变成噪点。 周期小于 12 px 的沟槽会在 bump 中产生 aliasing,并在明亮 environment 中闪出亮点。这里的 seigaiha 雕刻使用 0.15 m 单元,每个圆包含两道环(1440 宽时约 16 px);若改为 0.062 m,在 1x 下会变成噪点,在 2x 下才足够细腻。应根据像素预算确定雕刻尺寸,而不是依据模型。
木纹中出现砖块状斑块。 平锯顶面上的髓射线斑点(
rays
)会呈现为平铺网格。Kibori 的默认值 0.8 适用于径切面;在牌匾上设为 0,其他面上设为 0.15。
暗色墙面出现条带。 渐晕渐变中存在 8-bit 色阶。在 sRGB 编码后加入幅度为 +-0.0015 的 hash dither(
(hash - 0.5) * 0.003
)即可消除;scorecard 会统计连续 6 个及以上相同像素的游程(
flatRun
0.019)。
相机会动时,指标也会变化。 冻结摇摆动画和计时(
KiboriScore.freeze(true)
),在同一个 task 中完成渲染和 canvas 读取(不使用
preserveDrawingBuffer
),以固定尺寸和 DPR 捕获,并使用 headless Chromium;应用内面板会在后台运行,并与 GPU 共享资源。

Constants

常量

ItemValue
Camera34 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
TargetHalfFloat, MSAA 4, DPR cap 2
Key / fill / rim / hemi2.0 / 0.60 / 0.72 / 0.19 (Kibori fill 0.76)
Shafts5 planes 0.44-0.52 wide, length 2.75, gain 0.13, foot fade 0.22 (Kibori 0.30), fbm 3 octaves
Wall rays10 planes, 0.20-0.90 wide, length 2.2, gain 0.08
Dust1900 points, size
17 / depth
px clamped 3.4-46,
1 - smoothstep
bokeh, gated to within ~0.33 m of a beam axis
Field1024^2 RGBA8, 64 periodic cells (
n
,
f
, second
n
), mipmapped; 16 px for the "low" toggle
Last passunsharp 0.10, vignette
0.42 + 0.58 v
, ACES, sRGB, dither 0.003
项目数值
相机垂直 34 度,(0.60, 1.25, 2.55),看向 (0.05, 0.50, -0.30);宽高比低于 1 时使用更宽的 FOV
光线方向归一化的 (0.40, -0.75, 0.45),窗户中心 (-0.60, 1.40, -1.0),开口 1.5 x 1.5
渲染目标HalfFloat、MSAA 4、DPR 上限 2
主光 / 补光 / 轮廓光 / 半球光2.0 / 0.60 / 0.72 / 0.19(Kibori 补光为 0.76)
光束5 个平面,宽 0.44-0.52,长 2.75,gain 0.13,底部淡化 0.22(Kibori 为 0.30),fbm 3 个 octaves
墙面光线10 个平面,宽 0.20-0.90,长 2.2,gain 0.08
尘埃1900 个点,size
17 / depth
px,限制在 3.4-46,
1 - smoothstep
bokeh,仅允许出现在距光束轴约 0.33 m 内
噪声场1024^2 RGBA8,64 个周期性 cells(
n
、
f
、第二个
n
),带 mipmap;“low”开关使用 16 px
最后 passunsharp 0.10,vignette
0.42 + 0.58 v
,ACES,sRGB,dither 0.003

Cost (measured, headless Chromium on Apple Silicon, Metal)

成本(实测,基于 Apple Silicon、Metal 的 headless Chromium)

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.
完整管线渲染一帧并执行 1 px readback:在 1440x900 x1 下为 7.4-7.9 ms,在 2880x1800(DPR 2)下为 16.5 ms,在 390x844 DPR 2 下为 7.2 ms。Shaft 和 wall-ray 平面是开销最大的图层(每个加法混合像素执行一次 3-octave fbm:x1 时约 1 ms,x2 时约 5 ms);首帧之后 shadow map 不再产生逐帧成本,因为场景是静态的;grain、env、key 和 dust 每项约 0.2-0.7 ms。最省成本的调节手段是减少 shaft 的 fbm octave 数量(或以半分辨率绘制),而不是减少平面数量。不要根据一次开关测试下结论:在 2x 下,不同运行之间的波动约为 1 ms。

Scoring loop

评分循环

  1. Serve the page,
    npm i playwright-core
    , run
    node references/scorecard.mjs
    (
    --url
    ,
    --size 1440x900
    ,
    --dpr 1
    ,
    --target 8
    ,
    --no-ablate
    ,
    --sync
    ). It captures headless, freezes time, scores the eight items in
    references/rubric.md
    , ablates each layer and writes JSON; exit code 1 if any item is short.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  1. 为页面提供服务,运行
    npm i playwright-core
    ,然后运行
    node references/scorecard.mjs
    (
    --url
    、
    --size 1440x900
    、
    --dpr 1
    、
    --target 8
    、
    --no-ablate
    、
    --sync
    )。它会执行无头捕获、冻结时间、按照
    references/rubric.md
    对八项指标评分、逐层执行 ablation 并写入 JSON;如果任何一项低于目标,退出码为 1。
  2. 在修改任何内容之前先为 baseline 评分(关闭所有图层:这里为 3.7),并保留该文件。Ablation 是对指标的校验:如果移除某个图层反而提高总分,说明该指标测量有误,或该图层不值其成本。
  3. 优先修复得分最低项中成本最低的子指标,以相同尺寸和 DPR 重新运行,并且只保留画面也证实有效的改动。每次由指标驱动的更改后,都查看一张 2x 裁剪图;这个 demo 中有三项“改进”虽然提高了数值,却让板面看起来更差(整块板的 clearcoat、anisotropy 0.3、逐像素 tone gain 2.4),因此均已回退。
  4. 若要获得一个意味着“看起来正确”的分数,把捕获图和评分规则交给一位未看过数值的全新评审者,并取两次评分中的较低值。
  5. 如实报告。此 demo 在 1440x900 x1 下:平均 8.8;grain 8.1、key/fill 10、shadows 9.6、shafts 8.0、rays 8.8、tone 9.8、texture 9.7、specular 6.5(未达到 8:sheen 过宽,不是清晰的高光;为了增加清晰高光而做的修复破坏了 grain)。在 DPR 2 下,specular 项为 7.4,shafts 为 7.3(尘埃斑点比测量窗口更细)。

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.hidden
    and when the canvas leaves the viewport;
    dt
    clamped to 1/30 s, and the first frame after a pause has
    dt = 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-live
    region.
  • Below 820 px the panel moves under the canvas and a score chip sits on it.
  • prefers-reduced-motion
    :显示经过设计的静态画面(时间固定为 5.2,无摇摆、无尘埃漂移),按需渲染;图层开关和评分仍然可用。
  • 当
    document.hidden
    或 canvas 离开视口时暂停;
    dt
    限制为 1/30 s,暂停后的第一帧
    dt = 0
    。
  • 通过
    ResizeObserver
    获取尺寸;DPR 上限为 2;target 和 dust point size 随之调整。
  • 图层开关使用带可见焦点样式的真实 checkbox;分数是
    aria-live
    区域。
  • 低于 820 px 时,面板移到 canvas 下方,分数 chip 显示在其上。

Verify

验证

  • The metrics block in
    demo.html
    equals
    references/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.
  • demo.html
    中的 metrics 区块与
    references/metrics.js
    一致(
    node scorecard.mjs --sync
    )。
  • 在 1440x900 和 390x844 下控制台无报错,reduced motion 模式呈现构图完整的画面,Tab 顺序可到达每个开关。
  • 存在包含 baseline、all-on、ablation 和 cost 的 scorecard JSON,且与上述声明一致。