Track A: Opus 5.5 alone
- agent
- Opus 5.5 worker
- model
- claude-opus-5-5
- harness
- Claude Code
- started
- 2026-10-30 04:43 UTC
- took
- 40 min · 4 rounds · 101 turns
- verdict
- 1st, tied. Judged blind as Z; tied first with track B (owner torn). Interesting inside of the mouth; best eyelashes; didn't solve the closed-eye issue.
user
+0:00<teammate-message teammate_id="team-lead" summary="Benchmark track A: Opus alone">
You are track A of a three-track benchmark on improving a game's anime character expressions. Read these two files and follow them exactly; together they are your whole brief:
- ~/Projects/lowmarch-bench/brief/BRIEF.md
- ~/Projects/lowmarch-bench/brief/track-a.md
The reference materials are in ~/Projects/lowmarch-bench/brief/materials/ (look at the images with your Read tool).
Ground rules for this benchmark:
- Work alone and don't ask questions; make your own decisions. Don't open the other tracks' worktrees or work folders.
- Every shell command must start with
source ~/Projects/lowmarch-bench/kit/env-a.sh &&(the shell does not keep variables between commands). - Run builds and shoots in the foreground (not background tasks). The Bash tool stops a command at 10 minutes (pass timeout 600000), and the machine is shared with two other tracks, so things run slower: build expressions in groups of about five names per command if a full build would pass 9 minutes.
- Respect the budget: at most 6 rounds and 90 minutes from your start; write your start time in $WORK/NOTES.md first.
- When done, make sure $WORK/FINAL_woman.png, $WORK/FINAL_youth.png and $WORK/NOTES.md exist and your code is committed on branch bench-a in your worktree, then reply with the final summary the brief asks for (under 200 words).
agent
+0:08Bashcat ~/Projects/lowmarch-bench/brief/BRIEF.md; echo ----; cat ~/Projects/lowmarch-bench/brief/track-a.md; ls -la ~/Projects/lowmarch-bench/brief/materials/; cat ~/Projects/lowmarch-bench/kit/env-a.sh; date
command
cat ~/Projects/lowmarch-bench/brief/BRIEF.md; echo ----; cat ~/Projects/lowmarch-bench/brief/track-a.md; ls -la ~/Projects/lowmarch-bench/brief/materials/; cat ~/Projects/lowmarch-bench/kit/env-a.sh; date
result, 109 lines
# Benchmark brief: improve our characters' expressions You are one of three tracks given this same task from the same starting point. Work alone; do not look at the other tracks' folders (`~/Projects/lowmarch-bench/{a,b,c}` and `work/{a,b,c}` other than your own). ## The project Lowmarch is a code-built anime character pipeline for a tactics game. Characters are VRoid models reshaped in code, exported as GLBs and drawn in the browser by our own "painted" renderer (a three.js page). Everything is drawn with an orthographic camera. The owner reviews characters in that page, the "armory". ## The task Make our two test bodies' facial expressions read better, in the armory's painted renderer: - the slender woman (`arm_f_slender`); - the youth (`arm_m_youth`). The expressions are baked from VRoid's 57 expression shapes at build time. **The reference** is two commercial anime rigs, Yin and Yang. They're for study only, and nothing of theirs may be copied into the project. - In `brief/materials/`: - `expressions-vs-yin-yang.png`: their expressions beside ours at the starting point, 9 expressions and 5 lip-sync mouths at 0 and 30 to 35 degrees; - their individual frontal renders (`yin-expressions/`, `yang-expressions/`); - frames from their product page; - `yin-yang-rig-review.md`: notes on how their rig and shading work; - `vroid-shapes.txt`: the VRoid shape list. - Study what makes theirs read, then improve ours with our own code and recipes. **The 14 expressions** are in `$WORK/expr_list.txt`: neutral, joy, smile, angry, sad, surprised, shout, wink, smug, and the lip shapes A, I, U, E, O. - You may change the recipes, the build code and the renderer. - Keep the names. - Neutral is every character's default face in the armory, so it must still look right. **Decide one open question and justify it:** should eyebrows be drawn on top of the hair (bangs) when the hair covers them? Some anime styles do this and many don't, and the reference rigs keep their bangs above the brows instead. Decide for our characters, implement your decision, and explain it. ## Your setup - **Before every shell command,** run `source ~/Projects/lowmarch-bench/kit/env-<track>.sh` (or chain it with `&&`). It sets: - `LOWMARCH_REPO`: your git worktree. Edit code only here; it's on branch `bench-<track>`. - `WORK`: your builds, shots, sheets and notes. - `KIT`: the shared tools. Don't edit them. - **Where the code is, in your worktree:** - `review/vroid-02/index.html`: the painted renderer. The shaders are `PAINT_VS`, the material fragment shader and `PAINT_COMP` (the composite pass with lines). Roles: face=1, body=2, hair=3, line=4, iris=5, white=6, highlight=7, brow=8, mouth=9. - `tools/vroid_portrait.py`: the `Portrait` class builds a face. It applies the expression shapes, then `reshape`, `face_normals`, `shadow_cards` and more. - `tools/vrm.py`: the VRM loader. Shapes are applied in `VRM.primitives`. - `tools/export_glb.py`: the GLB export and the page's material data (`scene_info`). - `docs/portrait-guide.md`: our accumulated lessons. - **A round:** 1. Change code or recipes. 2. If you edited `tools/vroid_portrait.py` (or `cage.py`, `fit.py`, `proportions.py`, `makehuman.py`, `anthro.py`, `heads.py`, `collide.py`), run `cd $LOWMARCH_REPO && python3 -I tools/cage.py arm_f_slender arm_m_youth` (about 2 minutes). Those files key the body cache, and the bodies come out identical. 3. Build: `$KIT/build_expr.sh $WORK/expr_list.txt [names...]` (about 25 seconds per expression). Page-only changes need no rebuild. 4. Shoot: `$KIT/shoot_expr.sh $LOWMARCH_REPO/review/vroid-02/index.html r<N>` (about 4 minutes for all 14). Shots land in `$WORK/shot_r<N>/<expr>/painted_<body>.png`: a frontal tile, then a 30 degree tile. 5. Sheets: `$KIT/sheets.sh r<N> "Round N: what changed"` writes `$WORK/sheet_r<N>_woman.png` and `sheet_r<N>_youth.png` (before vs this round, frontal). 6. Look at the sheets, and at the 30 degree tiles too: an expression must hold off-front. - **Rules for running things:** - Run long commands in the foreground with generous timeouts, not as background jobs. - Work only in your worktree and `$WORK`. - Don't touch `~/Projects/lowmarch`, the other tracks, the shared kit or anything published. - Don't install anything. Headless only: no GUI windows. - You may commit to your branch. ## Budget At most 6 rounds and 90 minutes of wall-clock time, whichever comes first. Note your start time in `$WORK/NOTES.md`. ## Deliverables - `$WORK/FINAL_woman.png` and `$WORK/FINAL_youth.png`: copies of your best round's sheets. - `$WORK/NOTES.md`: - each round: what you changed, why, what the sheets showed, and how long it took; - your eyebrow decision and why; - what still reads weakly. - Your code committed on your branch. - A final message under 200 words: what you changed, and the rounds and time you used. ---- # Track A Your track letter is `a`: `source ~/Projects/lowmarch-bench/kit/env-a.sh`. Review your own work: after each round, look at the sheets yourself (open the PNGs) and decide the next round from what you see. No outside reviewer. total 2172 drwx------ 4 severian severian 4096 Oct 30 12:35 . drwx------ 3 severian severian 4096 Oct 30 12:36 .. -rw------- 1 severian severian 796998 Oct 30 12:35 expressions-vs-yin-yang.png -rw------- 1 severian severian 985 Oct 30 12:35 vroid-shapes.txt drwx------ 2 severian severian 4096 Oct 30 12:35 yang-expressions drwx------ 2 severian severian 4096 Oct 30 12:35 yin-expressions -rw------- 1 severian severian 1380961 Oct 30 12:35 yin-yang-product-gif-frames.png -rw------- 1 severian severian 19040 Oct 30 12:35 yin-yang-rig-review.md export BENCH=$HOME/Projects/lowmarch-bench export LOWMARCH_REPO=$BENCH/a export WORK=$BENCH/work/a export KIT=$BENCH/kit Fri Oct 30 12:43:15 PM PST 2026Bashsource ~/Projects/lowmarch-bench/kit/env-a.sh && ls -la $WORK $LOWMARCH_REPO; cat $WORK/expr_list.txt; ls $KIT; cat $WORK/NOTES.md 2>/dev/null; printf "# Track A notes\n\nStart: 2026-10-30 12:43 PST\n\n" > $WORK/NOTES.md; cd $LOWMARCH_REPO && git log --oneline | head -5 && git status | head
command
source ~/Projects/lowmarch-bench/kit/env-a.sh && ls -la $WORK $LOWMARCH_REPO; cat $WORK/expr_list.txt; ls $KIT; cat $WORK/NOTES.md 2>/dev/null; printf "# Track A notes\n\nStart: 2026-10-30 12:43 PST\n\n" > $WORK/NOTES.md; cd $LOWMARCH_REPO && git log --oneline | head -5 && git status | head
result, 54 lines
~/Projects/lowmarch-bench/a: total 36 drwx------ 6 severian severian 4096 Oct 30 12:34 . drwx------ 8 severian severian 4096 Oct 30 12:34 .. drwx------ 5 severian severian 4096 Oct 30 12:34 art drwx------ 2 severian severian 4096 Oct 30 12:34 docs -rw------- 1 severian severian 49 Oct 30 12:34 .envrc -rw------- 1 severian severian 58 Oct 30 12:34 .git -rw------- 1 severian severian 124 Oct 30 12:34 .gitignore drwx------ 5 severian severian 4096 Oct 30 12:34 review drwx------ 4 severian severian 4096 Oct 30 12:34 tools ~/Projects/lowmarch-bench/work/a: total 20 drwx------ 4 severian severian 4096 Oct 30 12:42 . drwx------ 6 severian severian 4096 Oct 30 12:42 .. -rw------- 1 severian severian 544 Oct 30 12:42 expr_list.txt drwx------ 16 severian severian 4096 Oct 30 12:42 shot_before drwx------ 2 severian severian 4096 Oct 30 12:42 site # expression recipes: name|VRoid shapes baked at build (Fcl_* names, weight; ';' between; 'none' = neutral) # the 14 the sheets show; keep these names (front_sheet.py reads them). Edit the recipes freely. neutral|none joy|Fcl_ALL_Joy:1 smile|Fcl_ALL_Fun:1 angry|Fcl_ALL_Angry:1 sad|Fcl_ALL_Sorrow:1 surprised|Fcl_ALL_Surprised:1 shout|Fcl_BRW_Angry:1;Fcl_EYE_Angry:1;Fcl_MTH_A:1 wink|Fcl_EYE_Close_R:1;Fcl_MTH_Fun:1 smug|Fcl_EYE_Close:0.3;Fcl_MTH_Up:0.7 lip_A|Fcl_MTH_A:1 lip_I|Fcl_MTH_I:1 lip_U|Fcl_MTH_U:1 lip_E|Fcl_MTH_E:1 lip_O|Fcl_MTH_O:1 arm_shoot.py build_expr.sh env-a.sh env-b.sh env-c.sh expr_list.txt front_sheet.py setup_base.sh sheets.sh shoot_expr.sh terra_review.sh variant_build.py 47dcd23 Armory: a skin picker over the new range (People and Fantasy), ?skin=<tone> for sheets 5a2b154 Skin range: 12 tones from porcelain to ebony and 7 fantasy tones, each with its own shading ramp 5e928b9 Armory v13.4 (trial): the chin's shadow cast in screen space from the jaw and hair 3105daf Armory v13.3: chin shadow fitted from the skeleton's neck line; shallower, tapered, softer fbffd7c Armory v13.2: shadow cards for the chin and nose, a smooth neck On branch bench-a nothing to commit, working tree clean
Read~/Projects/lowmarch-bench/brief/materials/expressions-vs-yin-yang.png
input
{ "file_path": "~/Projects/lowmarch-bench/brief/materials/expressions-vs-yin-yang.png" }result, 1 line
[redacted: reference rig image (Yin/Yang by DillonGoo, personal-use licence)]Bashcat ~/Projects/lowmarch-bench/brief/materials/yin-yang-rig-review.md; cat ~/Projects/lowmarch-bench/brief/materials/vroid-shapes.txt; ls ~/Projects/lowmarch-bench/brief/materials/*/
command
cat ~/Projects/lowmarch-bench/brief/materials/yin-yang-rig-review.md; cat ~/Projects/lowmarch-bench/brief/materials/vroid-shapes.txt; ls ~/Projects/lowmarch-bench/brief/materials/*/
result, 216 lines
# Yin & Yang rigs (DillonGoo Studios): review for lowmarch 2026-10-30. EEVEE builds (`_Yin_EV_Rig_Master.blend`, `_Yang_EV_Rig_Master.blend`, saved by Blender 4.3), opened in Blender 5.2.3 headless with `--disable-autoexec`. ## Short answer **Not a base.** The licence is personal/educational only: no commercial use of the characters, no redistribution "in original or modified form" (links below). A game build would redistribute them. Technically they are also a poor fit: two fixed characters whose clothes are the body (Yin has no skin under the shirt and pants), a Rigify A-pose rig split over 3 armatures (~1,640 bones), and expressions done with bones, not shape keys. **The lessons are worth a lot, and the ideas are free to reimplement.** Ranked for "too sharp from some angles" and "hold at every angle": 1. **Proxy shading normals ("Vertex Skeleton Normals").** The face is shaded from normals that point away from a simple line inside the head, blended in by a painted mask and blurred. They are not the mesh's own normals. The contact sheet shows the difference: rows 2 vs 3 and 5 vs 6, same lowmarch 4-band shader, rig normals vs mesh normals. With mesh normals the nose, mouth and cheeks break into noisy patches that change with yaw. With the proxy they make clean ovals at every yaw. 2. **One proxy for the whole head.** Hair, brows, lashes, mouth bag, teeth and tongue all take the same head proxy normal, so the hair's shade edge carries on into the face's. 3. **Few tones, low threshold.** Skin is 2 tones split at N·L > 0.1: only surfaces turned more than about 84° from the light go to shade. Everything else is authored: a painted shade mask and small shadow cards. 4. **Authored shadow shapes.** A painted per-corner `Shadows` attribute forces shade where the artist wants it. Nose, lip and eyelid shadows are tiny separate meshes in the shade colour. Yang's nose card has two flat normals, one per side, so the shadow shows only on the side away from the light. 5. **Line-only geometry.** `Eyelids` is 10 vertices and no faces. It exists only so Line Art draws the lid creases as 3D lines that turn with the head. Shading-only meshes are excluded from Line Art. ## Files and safety - Embedded texts, dumped and read: `scratchpad/rigs/texts/{Yin,Yang}/`. - `*_ui.py` (BODY 109/121 KB, FACE, PHYSICS) are stock Rigify-generated rig UI (Blender 4.3 template): panels, IK/FK snapping and bake operators. I diffed them line by line against Rigify's own sources. The only lines not in Rigify are layout calls, the rig_id and the 4.3-era fcurve API. The one `eval` is Rigify's standard `parse_bone_names`. There is no os/subprocess/socket/urllib/file I/O. - `DO_NOT_DELETE_META_DATA` is pure JSON of bone property defaults (no licence text). - `PUBLISH_SHOT_LOG` is the studio's publish checklist (W:\StudioGooThing paths, "Pack files"). - `Text` is a manual cleanup script that clears ID props and drops empty vertex groups. It is not a module, so it never runs on load. - Drivers: about 800 per rig. Only 6 are Python expressions, all arithmetic: `1-((var-N)**2)` for the eye-look parent switch, and `(var + .1125) % 0.225 - .1125` for the hand macro wrap. The sun_vec/rim_vec/light_fx drivers on Teeth/Tongue are simple expressions, and their targets are empty in the EEVEE build (leftovers from the Goo Engine build). - I judged the texts safe for autoexec, but the permission classifier refused `--enable-autoexec`. All renders therefore ran with autoexec off. That skips only the 6 drivers above, which have no visible effect at rest pose. ## 1. Techniques, how each works ### Vertex Skeleton Normals (GN, render-only) Head stack: `Armature > Subdivision (2, custom normals) > Warp x2 > GN "Blur Threshold" > GN "Vertex Skeleton Normals" > Lattice (Flattice)`. Both GN modifiers are off in the viewport and on in render. The node group `Head Vertex Skeleton Normals` (input Blur Iterations, default 5; hands use 48) works like this: 1. Store the mesh normal as point attribute `vs_nrm`. 2. Node `Neck_VS` (group `Edge Skeleton Projection`) runs with object `Yin_Neck_Pos`, factor 0.95, influence attribute `NRM_Neck`. Node `Head_VS` runs the same group with object `Yin_Head_Pos`, factor 0.5, influence `NRM_Head`. Each skeleton object is a few vertices skinned to the body rig. `Yin_Head_Pos` is a single vertical edge from z 1.42 to 1.755 at y -0.069, inside the head and forward of the skull's middle. 3. `Edge Skeleton Projection` takes the nearest point on the skeleton's edges Q (Geometry Proximity, EDGES) and the skeleton's mean point M (`Mean_Pos`). It forms `target = mix(M, Q, factor)` and computes `n = normalize(P - target)` (`Edge_Pos`). Then `vs_nrm = mix(vs_nrm, n, weight of the influence group)`. - Factor 1 gives capsule normals around the edge, factor 0 gives sphere normals around its centre. - The head uses 0.5: cylinder-like across the face, rounded over the crown and chin. - The neck uses 0.95: a cylinder. 4. Blur `vs_nrm` over mesh neighbours N times, normalize, store. Every material reads `vs_nrm` (Attribute node) into the shared toon group. Because the GN runs after Armature and before the Flattice lattice, the normals follow the pose but ignore the camera-facing flattening. Who carries the influence weights: - NRM_Head: Head 1,436 of 1,671 verts at weight 1. Hair, Eyebrows, Eyelashes, Mouthbag, Teeth and Tongue carry NRM_Head on all their verts, so they shade from the same head proxy. - NRM_Neck: 263 verts. - Hands use `Hand Vertex Skeleton Normals` (finger skeleton, 20 edges) plus a `sphere_nrm` about the mesh centroid. - Clothes use per-garment skeletons (`Vertex Skeleton Shirt/Pants/Shoe*`). In the material they mix 70-90% `vs_nrm` with an object-space Normal Map fed by `sphere_nrm` (frame "Mix Normals Types"). ### Toon shader (`Yin_Shader` / `Yang_Shader`, used by 16-18 materials) ``` N = mix(TexCoord.Normal, vs_nrm, Normals=1) v = ShaderToRGB(Diffuse(N, roughness 0)) * (ShadeMap - attr "Shadows" - Backfacing) colour = v > 0.1 ? Lit : Shaded ``` - There are two colours per material, set on the group node (Skin Lit 0.85/0.65/0.53, Shaded 0.59/0.30/0.21, linear). - `Shadows` is a painted corner colour attribute. 21% of Yin's and 26% of Yang's face corners are non-zero. - `thresh`, also painted, is blurred by GN `Blur Threshold` but isn't read in the EEVEE materials. It is presumably used in the Goo Engine build (unverified). - The EEVEE file still holds the Goo Engine groups `BASE_SunFace_Group` etc. They contain an undefined node "Shader Info" (half-lambert per light, multiplied by a ShadeMap, then a threshold), so the Goo Engine build shades per named sun with half-lambert. I didn't diff the GE files further. ### Lights - `Sun_Body`, `Sun_Face` and `Sun_Hair`: energy π, no shadows, all pointing +Y, i.e. lit from the character's front. - Each sun is parented to a rig bone (`Body_Sunvector`, `Head_Sunvector`, `Hair_Sunvector`), and `Hair_Sunvector` copies `Head_Sunvector`'s rotation. - The bones carry "Head space", neck_follow and head_follow switches, so a light can follow the head. - Light linking gives each sun its own receivers: Face takes head, teeth, eyes and shadow cards; Hair takes hair; Body takes clothes and hands. ### Shadow cards - Yin `Nose_Shadow` (9 v), `Lip_Shadow` (10 v), `Eyelid_Shadow` (30 v) use material `Skin_Shadow_Caster`: a constant Emission in the skin-shade colour. They are driven by face bones (`DEF-eyelid-shadow-*`) and excluded from Line Art. - Yang `Nose_Shadow` (38 v) is shrinkwrapped onto `Male_Face` (TARGET_PROJECT, 1 mm). GN `Geometry Nodes.005` sets `vs_nrm` to the mean normal of the "Left" group on the left half and of the "Right" group on the right. Material `NoseShaded` shows the shade colour where N·L < 0.1 and is transparent elsewhere, so the shadow is a flat shape that flips sides with the light. - Yang `Shorts_Shadows` is a constant shade band under the shorts. ### Warps and lattices - Head and both teeth meshes have `Warp`/`PG - Cheek Warp.L/R`: bone pair `cheek-warp-from.L` to `cheek-warp-to.L` (to is a child of from, same rest position), 3.5 cm smooth falloff. Moving the "to" bone pushes the cheek and the teeth follow, with no weights painted. - Tongue has three of the same. - Yang adds `Head_Squash_Lattice` with Hook_Top/Hook_Bottom for head squash and stretch. - Flattice: - 4x2x4 B-spline lattice whose `Flatten` key moves the two depth layers ±0.5 together. - Driven by bone prop `General Properties["Flattice"]` (default 0). - `Flattice_Empty` copies location from DEF-spine.006 and damped-tracks `Camera_Reference_Flattice_Empty`, whose location is driven to the scene camera's. - It sits last in the stack, after the normals. - Under our orthographic camera, squashing along the view axis changes only depth order, not the silhouette. ### Eyes `Eye_Reflection` is a 358-vert shell over the eyes (BLENDED, excluded from Line Art). It draws procedural highlights from SDF circles in UV space (`white_highlights`, `purple_highlights`) with an animated Wave-texture "wibble". Iris colour comes from one packed `Eye_Texture.png`. ### Line Art Three Grease Pencil objects, each with Line Art modifiers, `Screen Space Thickness` GN, Taper and Overall Thickness: - `LineArt.Body`: collection `Yin_Main_Body`, contour+crease+edge marks+intersection+loose. - `LineArt.Character`: separate modifiers for Eyelashes, Head, Head gap-fill, Teeth, Mouthbag, Eyelids and Eyebrows. Each has contour, edge marks and loose edges; crease is off on the face. - `LineArt.Hair`. Shading meshes are set to EXCLUDE. I couldn't evaluate Line Art headless (0 strokes), so the outlines on the sheet are my approximation from an ID/depth pass. ### Face rig (`*_Rig_FACE`, 648 bones, 141 deform) - Lips: lip MASTER/top/bot/corner plus tweak rings, `lip-zipper-smoother`, `jaw-yapper` and `jaw-influence`. - Eyes: 12 eyelid bones per eye, `MCH_Eye_Tilt_upper/lower`, eye-socket bones named after the orbital bones, `eye-look-MASTER` with 4-way parent switch, `eye-look-blink-macro`. - Brows: 5 brow bones and 5 brow-skin bones per side, plus `brow-shape-control`. - Also cheek warps, nose bones, `eyelid-shadow-*`, and `sync-control`/`sync-frame`. - Correctives: Eyelashes `COR - Blink.L/R` (driver `var / -0.025` on blink target Z), shirt elbows, pants knees/butt. ### Body and hair - `*_Rig_BODY` is Rigify (590/584 bones, 176/172 deform) with knee/elbow helper bones, finger "Fatten"/"Knuckle" deform bones and `Palm_squish`. A hand-macro control curls the fist from one slider. - `*_Rig_PHYSICS` holds the hair chains (Yin 68 deform, Yang 50): FK + Tweak + `-PHYSICS` bones joined by Copy Rotation/Transforms. `wiggle_enable` props suggest the Wiggle 2 add-on (unverified, not installed). ## 2. What transfers to lowmarch, ranked lowmarch shades `lit = n @ LIGHT` from interpolated vertex normals (`tools/vroid_portrait.py` render(), LIGHT about (0.08, 0.06, 1) in camera space) into 4 skin bands (0.80/0.62/0.38). 1. **Proxy face normals (highest value).** After posing, for face, hair, brow, line and mouth vertices: - Take the head segment from the head bone (neck-top to crown), set about 0.25 of the head's depth forward of the skull centre. - `target = lerp(centroid, closest_point_on_segment(P), 0.5)`, `n_proxy = normalize(P - target)`. - `n = normalize(lerp(n_mesh, n_proxy, w))`, then 5 rounds of neighbour averaging over the mesh's edges and normalize. Pass `n` to `interpolate(prims, buf, "nrm")` in place of the stored normals. - The weight `w` is per template vertex. All 26 VRoid faces share the skin triangles, so the mask is authored once by index (doc lesson 29): 1 on the face and hair, falling to 0 at the ears and the seam to the skull. - Add a neck capsule (factor 0.95) so the jaw-over-neck edge stays clean. - The sheet's rows 2/3 and 5/6 are this exact A/B under our bands and headlight. 2. **Same proxy for hair, brows, lashes and mouth.** It is free once (1) exists. The hair's band edges then line up with the face's, and the forehead hair-shadow rule in `ink()` stops fighting the face bands. 3. **Fewer skin tones.** Try 2 skin tones (maybe 3), with the split at a low N·L (0.1 to 0.3) on proxy normals, and keep 4 bands for cloth and metal. With a near-frontal light this leaves faces mostly flat lit with a shade rim, the look these rigs get. Four bands on a near-sphere make concentric rings (row 2), which read "3D", not anime. 4. **Authored shade.** - A per-template-vertex "forced shade" weight (under the chin, eye sockets, below the lower lip), subtracted before banding as in `Yin_Shader`. - Nose and lip shadows as small fixed shapes in UV space, drawn like `MARK_WINDOW` marks, with a two-sided nose shadow chosen by the sign of the light's x in head space (Yang's `NoseShaded`). 5. **3D line geometry for face lines.** Lid creases and the nose line as polylines on the template, projected each frame (as `draw_curve_mouth` already does for the mouth) rather than texture marks. They keep their place at every yaw. 6. **Per-part light.** A face light defined in head space (or camera space but clamped so the face never goes mostly dark at yaw 90), separate from the scenery SUN and the body light. The rigs do this with bones and light linking. 7. **Shading before shape tricks.** Compute normals before any view-dependent squash (Flattice), so shape fixes don't move shade edges. Flattice itself does nothing to an orthographic silhouette. 8. **Warp-style face controls.** A bone-pair Warp (3.5 cm smooth falloff) is the same idea as our sculpt field. There's nothing new for us beyond confirming the approach. ## 3. As a base | | Yin / Yang | lowmarch VRoid | |---|---|---| | Mesh | Quad cages + Catmull-Clark level 2 at render. Head 1,671 v / 1,750 f (Yang 1,703 / 1,752), hair 1,018 (Yang 289+557+597), shirt 2,734. About 28k faces on the head after subdivision. | Triangulated game mesh. Face about 3.2k-4.2k tris on one shared template, body about 11.6k tris. | | Body | Clothes are the body. Yin: shirt, pants, hands, socks, shoes, no skin under them. Yang: arm and leg skin meshes masked under the clothes. | Nude base, costumes in code. | | Height | Yin 7.3 heads (crown 1.675, chin 1.444). Yang about 7.8 heads by the face mesh crown (1.632, chin 1.423). | arm_f_slender 7.3 (crown 1.661, chin 1.434); arm_m_youth 6.5 (1.623 / 1.375). | | Eye line, down from crown | Yin 0.58, Yang 0.55 (eyeball centres). | 0.56-0.64 (docs/vroid.md); 0.61 on both armory heads. | | Expressions | Bones (141 face deform bones) plus a few correctives. | 57 VRoid blendshapes. | | Rig | Rigify, 3 armatures, A-pose, many MCH/helper bones. | VRM humanoid, T-pose, spring bones + spheres. | - **Pose mapping:** feasible but not drop-in. - DEF-spine to `.006` map to hips, spine, chest, upperChest, neck and head. - DEF-shoulder, DEF-upper_arm (+.001 twist), DEF-forearm (+.001), DEF-hand, 3-bone fingers and thumb map to the arms and hands. DEF-thigh/shin/foot/toe map to the legs. - The A-pose rest needs a retarget to VRM's T-pose. The face and hair armatures must be merged. - **Spring bones:** hair spring bones could hang on the `DEF-Hair_*` chains (3 to 13 bones each). - **Adapting to archetypes:** poor. Two characters only, clothing fused to the silhouette, and the face rig and its warps and shadow cards are tuned to these two faces. - **Face design:** Yang sets the eyes higher than any VRoid sample (an older look, which our set lacks per lesson 30). The shapes are a reference to study, not to ship. **Licence** (verified 2026-10-30 on the listings): - [Gumroad bundle](https://dillongoo.gumroad.com/l/yin-yang-anime-twins-rig-pack), [Yin](https://dillongoo.gumroad.com/l/yin-anime-girl-rig), [Yang](https://dillongoo.gumroad.com/l/yang-anime-boy-rig): "non-exclusive right to use the assets on a royalty-free basis for personal or educational use". "You may not sell, share or otherwise redistribute the assets in original or modified form". "you cannot use the character 'Yin' or 'Yang' for your own commercial projects". Personal projects such as demo reels are fine with credit to DillonGoo Studios. - [Superhive](https://superhivemarket.com/products/yin-yang-twins-anime-rig-pack) lists License: **Editorial** and the same text. Its [FAQ](https://superhivemarket.com/products/yin-yang-twins-anime-rig-pack/faq): "If the game or movie is a commercial project (intended to make money), then the answer is no". Modifying for VRChat is fine, but "you are not allowed to redistribute the model". - So: modifying is allowed for personal use only. Shipping in a commercial game is not allowed. Redistributing in a build is not allowed, even modified. - Unverified: whether a character "built on" the mesh but substantially changed escapes this. Treat it as a derivative (no). Reimplementing the techniques (normals from a skeleton, two-tone thresholds, shadow cards) in our own code copies no asset. I am not giving legal advice. - The FAQ text mentions "Emily" in places, so it's a shared FAQ across their rigs. ## 4. Renders - No EGL or Vulkan here, so EEVEE can't run headless. Renders are Cycles CPU via `scripts/cycles_turn.py`: - Each `Diffuse > Shader to RGB` is replaced by `max(0, dot(N, L))`. That is equal to EEVEE for these shadowless suns of strength π on white diffuse. - Grease Pencil is removed and outlines are drawn from an object-ID and depth pass (`scripts/sheet.py`). - Ortho camera, scale 0.56 m, 640 px, light as noted per row. - lowmarch heads: `scripts/lm_turn.py` calls `vroid_portrait.Portrait.render` and `smooth_view` on the newest `arm_f_slender` and `arm_m_youth` cage VRMs, read-only. lowmarch's positive yaw turns the other way from mine. - **Contact sheet:** `/tmp/claude-1000/-home-severian-Projects/d2fdcea1-0c48-4814-81a2-89cf837a9b4f/scratchpad/rigs/out/contact_sheet.png` (and `.jpg`). Per-yaw PNGs are `out/{yin,yang}_{ship,bands,bands_novs}_yNN_lined.png` and `out/lm_*`. - Rows on the sheet: - 1 and 4: as shipped (2 tones, sun from the front, fixed in the world). - 2 and 5: our 4 bands with our headlight on the rig's normals. - 3 and 6: the same on the mesh's normals. - 7 to 10: lowmarch. ## Not verified - The look in EEVEE and in Goo Engine. Line Art strokes (they didn't evaluate headless). - The 6 Python drivers (autoexec refused). - Whether GN modifier inputs differ from the group defaults (Blur Iterations 5 head, 48 hands). The 5.2 API wouldn't read them. - The Wiggle 2 dependency. Goo Engine-only shading (`thresh`, Shader Info half-lambert). ## Addendum, 2026-10-30 The owner ran `cycles_turn.py ... ship` on both rigs with `--enable-autoexec` (the embedded Rigify UI scripts loaded). All 8 renders (Yin and Yang at yaw 0, 35, 60, 90) are pixel-identical to the autoexec-off renders: the 6 Python drivers have no effect at rest pose. The Teeth/Tongue shader-vector drivers and the CORRECTIVE_Blink drivers stay invalid either way, since their targets are empty in the EEVEE build. The 57 VRoid expression shapes on our faces (morph targets, applied by tools/vrm.py VRM.primitives; a recipe key matches a target whose name ends with it): Fcl_ALL_Angry Fcl_ALL_Fun Fcl_ALL_Joy Fcl_ALL_Neutral Fcl_ALL_Sorrow Fcl_ALL_Surprised Fcl_BRW_Angry Fcl_BRW_Fun Fcl_BRW_Joy Fcl_BRW_Sorrow Fcl_BRW_Surprised Fcl_EYE_Angry Fcl_EYE_Close Fcl_EYE_Close_L Fcl_EYE_Close_R Fcl_EYE_Fun Fcl_EYE_Highlight_Hide Fcl_EYE_Iris_Hide Fcl_EYE_Joy Fcl_EYE_Joy_L Fcl_EYE_Joy_R Fcl_EYE_Natural Fcl_EYE_Sorrow Fcl_EYE_Spread Fcl_EYE_Surprised Fcl_HA_Fung1 Fcl_HA_Fung1_Low Fcl_HA_Fung1_Up Fcl_HA_Fung2 Fcl_HA_Fung2_Low Fcl_HA_Fung2_Up Fcl_HA_Fung3 Fcl_HA_Fung3_Low Fcl_HA_Fung3_Up Fcl_HA_Hide Fcl_HA_Short Fcl_HA_Short_Low Fcl_HA_Short_Up Fcl_MTH_A Fcl_MTH_Angry Fcl_MTH_Close Fcl_MTH_Down Fcl_MTH_E Fcl_MTH_Fun Fcl_MTH_I Fcl_MTH_Joy Fcl_MTH_Large Fcl_MTH_Neutral Fcl_MTH_O Fcl_MTH_SkinFung Fcl_MTH_SkinFung_L Fcl_MTH_SkinFung_R Fcl_MTH_Small Fcl_MTH_Sorrow Fcl_MTH_Surprised Fcl_MTH_U Fcl_MTH_Up ~/Projects/lowmarch-bench/brief/materials/yang-expressions/: angry_y00.png joy_y00.png lip_A_y00.png lip_E_y00.png lip_I_y00.png lip_O_y00.png lip_U_y00.png neutral_y00.png sad_y00.png shout_y00.png smile_y00.png smug_y00.png surprised_y00.png wink_b_y00.png ~/Projects/lowmarch-bench/brief/materials/yin-expressions/: angry_y00.png joy_y00.png lip_A_y00.png lip_E_y00.png lip_I_y00.png lip_O_y00.png lip_U_y00.png neutral_y00.png sad_y00.png shout_y00.png smile_y00.png smug_y00.png surprised_y00.png wink_b_y00.pngBashsource ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && wc -l review/vroid-02/index.html tools/*.py docs/*.md && cat $KIT/build_expr.sh $KIT/shoot_expr.sh $KIT/sheets.sh && ls $WORK/shot_before $WORK/site | head -30
command
source ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && wc -l review/vroid-02/index.html tools/*.py docs/*.md && cat $KIT/build_expr.sh $KIT/shoot_expr.sh $KIT/sheets.sh && ls $WORK/shot_before $WORK/site | head -30
result, 112 lines
1677 review/vroid-02/index.html 611 tools/anthro.py 888 tools/cage.py 300 tools/checks.py 139 tools/clips.py 271 tools/collide.py 61 tools/compare.py 140 tools/duel.py 482 tools/export_glb.py 152 tools/features.py 327 tools/fit.py 322 tools/fitting.py 984 tools/gear.py 219 tools/hair_check.py 318 tools/hair.py 458 tools/head3d.py 661 tools/heads.py 935 tools/makehuman.py 291 tools/mass.py 311 tools/mobs.py 1272 tools/outfit.py 302 tools/pix.py 487 tools/plates.py 115 tools/poses.py 331 tools/proportions.py 207 tools/quaternius.py 118 tools/regress.py 190 tools/rig.py 541 tools/saber_study.py 127 tools/study.py 77 tools/terrain.py 269 tools/vecpix.py 257 tools/vrm.py 1192 tools/vroid_portrait.py 136 docs/anthro.md 33 docs/makehuman.md 1332 docs/portrait-guide.md 219 docs/proportions.md 47 docs/quaternius.md 12 docs/study.md 158 docs/vroid.md 16969 total #!/bin/bash # Build both bodies (arm_f_slender, arm_m_youth) in each expression of LIST from your worktree's code, into # $WORK/exb/<name>/. Run tools/cage.py first if you edited tools/vroid_portrait.py (setup.sh does it). # build_expr.sh LIST [only these names...] set -u LIST=$1; shift while IFS='|' read -r name rest; do [ -z "$name" ] && continue case "$name" in \#*) continue;; esac if [ $# -gt 0 ] && [[ ! " $* " =~ " $name " ]]; then continue; fi d=$WORK/exb/$name; rm -rf "$d"; mkdir -p "$d"; cp "$WORK/site/camera.json" "$d/" timeout 900 python3 "$KIT/variant_build.py" "$d" "rest=$rest" arm_f_slender arm_m_youth > "$WORK/log_build_$name.txt" 2>&1 \ && echo "built $name" || { echo "BUILD FAILED $name"; tail -5 "$WORK/log_build_$name.txt"; } done < "$LIST" #!/bin/bash # Shoot every built expression ($WORK/exb/*) through PAGE (normally $LOWMARCH_REPO/review/vroid-02/index.html): # a frontal face tile and a 30 degree tile per body, into $WORK/shot_<TAG>/<name>/painted_<body>.png # shoot_expr.sh PAGE TAG set -u PAGE=$1; TAG=$2 site=$WORK/shootsite_$TAG; rm -rf "$site"; cp -r "$WORK/site" "$site" python3 - "$PAGE" "$site/index.html" <<'PY' import sys s = open(sys.argv[1], encoding="utf-8").read() i = s.index("const ARMORY_ROWS = ["); j = s.index("const DUEL_YAWS") s = s[:i] + '''const ARMORY_ROWS = [ { label: "Face", tw: 480, th: 480, tiles: [{ clip: "ref", t: 0, yaw: 0, pitch: 2, focus: "J_Bip_C_Head", dy: 0.045, size: 0.22, label: "0°" }, { clip: "ref", t: 0, yaw: 30, pitch: 2, focus: "J_Bip_C_Head", dy: 0.045, size: 0.22, label: "30°" }] }, ]; ''' + s[j:] open(sys.argv[2], "w", encoding="utf-8").write(s) PY for d in "$WORK"/exb/*/; do name=$(basename "$d") cp "$d"/*.glb.json "$d/camera.json" "$site/" rm -rf "$WORK/shot_$TAG/$name" ROWS=0 timeout 900 python3 "$KIT/arm_shoot.py" "$site" "$WORK/shot_$TAG/$name" arm_f_slender arm_m_youth > /dev/null || echo "SHOOT FAILED $name" done rm -rf "$site" echo "shot $TAG" #!/bin/bash # Frontal sheets for both bodies: before vs a round. sheets.sh TAG "caption" -> $WORK/sheet_<TAG>_{woman,youth}.png set -u TAG=$1; CAP=$2 python3 "$KIT/front_sheet.py" "$WORK/sheet_${TAG}_woman.png" arm_f_slender "Woman, slender: expressions, front" "before:Before" "$TAG:$CAP" | tail -1 python3 "$KIT/front_sheet.py" "$WORK/sheet_${TAG}_youth.png" arm_m_youth "Man, youth: expressions, front" "before:Before" "$TAG:$CAP" | tail -1 ~/Projects/lowmarch-bench/work/a/shot_before: angry joy lip_A lip_E lip_I lip_O lip_U neutral sad shout smile smug surprised wink ~/Projects/lowmarch-bench/work/a/site: arm_f_slender.glb.json arm_m_youth.glb.json camera.json checks.json index.html plains.glb.jsonRead~/Projects/lowmarch-bench/work/a/shot_before/angry/painted_arm_f_slender.png
input
{ "file_path": "~/Projects/lowmarch-bench/work/a/shot_before/angry/painted_arm_f_slender.png" }result
no output
Read~/Projects/lowmarch-bench/brief/materials/yin-expressions/angry_y00.png
input
{ "file_path": "~/Projects/lowmarch-bench/brief/materials/yin-expressions/angry_y00.png" }result, 1 line
[redacted: reference rig image (Yin/Yang by DillonGoo, personal-use licence)]Bashsource ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && grep -n -i "brow\|mouth\|role\|renderOrder\|depthTest\|depthFunc\|PAINT_COMP\|PAINT_VS" review/vroid-02/index.html | head -120
command
source ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && grep -n -i "brow\|mouth\|role\|renderOrder\|depthTest\|depthFunc\|PAINT_COMP\|PAINT_VS" review/vroid-02/index.html | head -120
result, 35 lines
52:.brand .eyebrow { font: 10px/1.3 var(--f-label); letter-spacing: 0.1em; text-transform: uppercase; color: var(--screen-dim); } 126: <div class="brand"><span class="eyebrow">Lowmarch · Model viewer</span><h1>Lowmarch cast</h1></div> 127: <span class="seg" role="group" aria-label="View"> 134: <span class="seg" id="who" role="group" aria-label="Character"></span> 135: <span class="seg" role="group" aria-label="Pose" id="poseSeg" hidden> 141: <span class="seg" role="group" aria-label="Motion" id="motionSeg" hidden> 146: <span class="seg" role="group" aria-label="Animation" id="clipSeg"> 152: <span class="seg" role="group" aria-label="Renderer"> 183: <li><b>Builds and faces vary.</b> The soldier's kit is also worn by a stocky grey veteran and a slim woman. Builds run from petite (the thief, 1.4 m) to huge (the berserker), with a new overall height control and oversized hands, as the study's units have. Faces differ in shape (jaw, chin, brow, nose, cheeks), expression, hair and eye colour, skin tone, and beards and moustaches. The lineup's check sheet has rows of faces with helmets off and on.</li> 242:// role ids for the info pass (features 4-9 get no inner lines; hair 3 and 11 shade the forehead) 243:const ROLE_ID = { face: 1, body: 2, hair: 3, line: 4, iris: 5, white: 6, highlight: 7, brow: 8, mouth: 9, cloth: 10, hair_b: 11 }; 244:let nextRole = 12; 245:const roleId = r => (ROLE_ID[r] ??= nextRole++); 256: document.querySelector(".brand .eyebrow").textContent = PAGE.eyebrow || "Lowmarch"; 316:const PAINT_VS = `${SKIN_HEAD} 500:const PAINT_COMP = ` 568:const paint = compPass(PAINT_COMP, { tColor: { value: null }, tInfo: { value: null }, tDepth: { value: null }, uRes: { value: new THREE.Vector2() }, 802:function paintMaterial(V, role, map, index, infoRole = role, decal = false) { 803: const Rl = V.roles[role] || { mode: "flat", color: "#ff00ff" }, P = (V.paint || {})[role]; 806: uInfo: { value: new THREE.Vector4(roleId(infoRole) / 255, (index % 256) / 255, Math.floor(index / 256) / 255, 1) }, 807: uEye: { value: V.roles.iris.colors.map(hex) }, uFlat: { value: new THREE.Color() }, uSkinRef: { value: new THREE.Vector3(0.965, 0.878, 0.80) }, 826: if (role === "face" && map) u.uFaceTex.value = 1; 827: if (role === "hair" && map) u.uAlpha.value = 1; 828: if (role === "iris") { u.uKind.value = 7; u.uAlpha.value = 1; } 829: else if (role === "white" || role === "highlight") { u.uKind.value = map ? 8 : 9; if (map) u.uAlpha.value = 1; u.uFlat.value = hex(Rl.color || "#ffffff"); } 830: else if (!P && Rl.mode === "flat") { u.uKind.value = 9; u.uFlat.value = hex(Rl.color); if (map) u.uAlpha.value = role === "line" ? 2 : 1; } 832: const m = new THREE.ShaderMaterial({ uniforms: u, vertexShader: PAINT_VS, fragmentShader: PAINT_FS, side: THREE.DoubleSide, 860: const m0 = o.material, role = o.userData.role || "body", raw = rawTex(m0.map); 861: const infoRole = role === "hair" && opts.hairB ? "hair_b" : role === "shadow" ? "body" : role; 866: if (role === "hair" && m0.map && V.paint && V.paint.hair) { 880: o.userData.mats = { clay, toon, painted: paintMaterial(V, role, raw, meshIndex, infoRole, decal) }; 883: o.userData.role = role; 905: const band = info.band || "gold", BR = V.roles[band] || V.roles.gold; 1346: // the info pass (roles, ids, depth) first: the colour pass reads it for cast shadows 1371:// drawn into one image, which lands in #sheetOut as a PNG data URL (a headless browser reads it).Bashsource ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && sed -n 316,570p review/vroid-02/index.html
command
source ~/Projects/lowmarch-bench/kit/env-a.sh && cd $LOWMARCH_REPO && sed -n 316,570p review/vroid-02/index.html
result, 255 lines
const PAINT_VS = `${SKIN_HEAD} attribute float edge; attribute vec4 _plate; varying vec3 vN; varying vec2 vUv; varying vec3 vView; varying vec3 vObj; varying float vEdge; varying vec4 vPlate; void main() { ${SKIN_BODY} vN = normalize(normalMatrix * objectNormal); vUv = uv; vObj = position; vEdge = edge; vPlate = _plate; vec4 mv = modelViewMatrix * vec4(transformed, 1.0); vView = -mv.xyz; gl_Position = projectionMatrix * mv; }`; const HSV = ` vec3 rgb2hsv(vec3 c) { vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); float d = q.x - min(q.w, q.y); return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + 1e-10)), d / (q.x + 1e-10), q.x); } vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); }`; // painted renderer, after the Unicorn Overlord study: tight terminators, hue-shifted ramps built from // one base colour per material, hard reflection bands on steel, strand highlights on hair, a stage // grade and a back rim const PAINT_FS = ` uniform int uKind; uniform vec3 uBase; uniform vec3 uKey; uniform sampler2D uMap; uniform int uAlpha; uniform int uPass; uniform vec4 uInfo; uniform vec3 uEye[3]; uniform vec3 uFlat; uniform vec3 uSkinRef; uniform vec3 uGold; uniform vec3 uAmbient; uniform vec3 uKeyCol; uniform vec3 uRimCol; uniform float uGrain; uniform int uFaceTex; uniform int uDecal; uniform vec3 uMotif; uniform vec3 uBand; uniform vec3 uSkinTone; uniform int uShadeOnly; uniform int uSkinRamp; uniform vec3 uSkinL; uniform vec3 uSkinS; uniform vec3 uSkinC; uniform sampler2D tCastInfo; uniform sampler2D tCastDepth; uniform vec2 uCastRes; uniform int uCastOn; uniform float uCastNear; uniform float uCastFar; uniform float uCastPpm; // Cast shadows on skin, in screen space (the "depth offset" shadows of anime toon shaders): walk from this pixel // toward the key light across the info pass already drawn; if the face (the jaw) or hair stands in front of that // ray, this pixel is in its shadow. The shape is the caster's own outline as the camera sees it, at any angle. float castShadow() { vec2 uv = gl_FragCoord.xy / uCastRes, px = 1.0 / uCastRes; float z0 = uCastNear + texture2D(tCastDepth, uv).x * (uCastFar - uCastNear); float sxy = length(uKey.xy); if (sxy < 0.05) return 0.0; vec2 dir = uKey.xy / sxy; float lz = uKey.z / sxy; // the ray's rise toward the camera per metre across for (int s = 1; s <= 14; s++) { float dm = float(s) * 0.003; // 3 mm steps across the screen, up to 4.2 cm vec2 q = uv + dir * dm * uCastPpm * px; vec4 ni = texture2D(tCastInfo, q); if (ni.a < 0.5) continue; int r = int(ni.r * 255.0 + 0.5); if (r != 1 && r != 3 && r != 11) continue; // casters: the face (jaw, chin) and hair float zq = uCastNear + texture2D(tCastDepth, q).x * (uCastFar - uCastNear); float zr = z0 - lz * dm; if (zq < zr - 0.004 && zq > zr - 0.20) return 1.0; } return 0.0; } varying vec3 vN; varying vec2 vUv; varying vec3 vView; varying vec3 vObj; varying float vEdge; varying vec4 vPlate; ${HSV} float hash(vec3 p) { p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); } float vnoise(vec3 x) { vec3 i = floor(x), f = fract(x); f = f * f * (3.0 - 2.0 * f); return mix(mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x), mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y), mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x), mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z); } float toward(float h, float target, float amt) { float d = target - h; d -= floor(d + 0.5); return fract(h + clamp(d, -amt, amt)); } vec3 tone(vec3 base, float hue, float amt, float sMul, float vMul) { vec3 h = rgb2hsv(base); return hsv2rgb(vec3(toward(h.x, hue, amt), clamp(h.y * sMul, 0.0, 1.0), clamp(h.z * vMul, 0.0, 1.0))); } float st(float x, float t) { return smoothstep(t - 0.03, t + 0.03, x); } // Steel and gold. Hard reflection bands (dark below the horizon, a dark core under a white streak) // read well where the surface turns fast: rims, tubes, lames. On a broad plate the reflection // barely changes, so a band there fills the whole plate (the blotches): bands fade by how sharply // the surface curves (normal change per metre on screen), and broad plates get a soft tone that // lightens up each outline plate (hgt: height in the plate 0..1, or -1 without plate data). vec3 metal(bool gold, vec3 base, vec3 n, vec3 v, float lit, float hgt) { vec3 r = reflect(-v, n); float ry = r.y + (vnoise(vObj * vec3(3.0, 14.0, 3.0)) - 0.5) * 0.14; // jitter the bands along their length, not into blobs float curv = length(fwidth(n)) / max(length(fwidth(vView)), 1e-5); float sharp = smoothstep(5.0, 14.0, curv); // a shaped plate's domes (over the bust, a crowned panel) turn the bands into closed rings: plates // keep only a little of them and take a crisp highlight toward the light instead if (hgt >= 0.0) sharp *= 0.25; else sharp = max(sharp, 0.3); vec3 s0, s1, s2, s3; if (!gold) { s0 = tone(base, 0.68, 0.10, 2.4, 0.32); s1 = tone(base, 0.68, 0.06, 1.7, 0.76); s2 = tone(base, 0.12, 0.0, 0.8, 0.88); s3 = vec3(0.97, 0.96, 0.92); } else { s0 = vec3(0.30, 0.20, 0.30); s1 = tone(base, 0.20, 0.03, 1.0, 0.72); s2 = base; s3 = tone(base, 0.16, 0.03, 0.5, 1.5); } vec3 hard = mix(s0, s1, st(ry, -0.42)); // facing down: dark hard = mix(hard, s2, st(ry, -0.08)); // facing us: the plate's own grey hard = mix(hard, s0, st(ry, 0.36)); // dark core under the streak hard = mix(hard, s3, st(ry, 0.47)); // the white streak hard = mix(hard, s2 * 1.06, st(ry, 0.70)); float k = smoothstep(-0.55, 0.30, r.y); // broad: darker toward the ground (no jitter: if (hgt >= 0.0) k = clamp(k * 0.45 + hgt * 0.80 - 0.18, 0.0, 1.0); // on a flat plate it streaks) else k = clamp(k * 0.6 + n.y * 0.35 + 0.05, 0.0, 1.0); // without plate data: lighter facing up vec3 soft = mix(s1 * 0.9, s2, k); float hl = dot(n, normalize(uKey + v)); // the key's highlight, a hard-edged shape if (hgt >= 0.0) soft = mix(soft, mix(s2, s3, 0.6), st(hl, 0.955) * smoothstep(0.45, 0.8, hgt)); // the upper part only vec3 col = mix(soft, hard, sharp); return mix(col * 0.72 + s0 * 0.28, col, st(lit, 0.30)); } void main() { vec4 tex = texture2D(uMap, vUv); if (uAlpha == 1 && tex.a < 0.5) discard; if (uAlpha == 2 && (tex.a < 0.5 || dot(tex.rgb, vec3(1.0 / 3.0)) >= 0.42)) discard; if (uPass == 1) { gl_FragColor = uInfo; return; } vec3 n = normalize(vN); if (!gl_FrontFacing) n = -n; vec3 v = normalize(vView); float lit = dot(n, uKey) * 0.5 + 0.5; if (uShadeOnly == 1) lit = min(lit, 0.37); // shadow cards only ever darken skin, to a soft shade else if (uCastOn == 1 && uKind == 1 && int(uInfo.x * 255.0 + 0.5) == 2 && castShadow() > 0.5) lit = min(lit, 0.37); // body skin under the jaw's or the hair's shadow float g1 = vnoise(vObj * 40.0), g2 = vnoise(vObj * 150.0); vec3 col; if (uKind == 0 || uKind == 5 || uKind == 6) { // cloth, leather, ground vec3 hb = rgb2hsv(uBase); bool cool = hb.x > 0.42 && hb.x < 0.86; float sh = uKind == 5 ? 0.93 : (cool ? 0.70 : 0.96); float li = cool ? 0.52 : 0.13; vec3 c0 = tone(uBase, sh, 0.09, 1.12, 0.40), c1 = tone(uBase, sh, 0.05, 1.08, 0.64); vec3 c3 = tone(uBase, li, 0.03, 0.80, 1.20); float l = lit + (g1 - 0.5) * uGrain - (uKind == 6 ? 0.22 : 0.0); // ground: a flat top sits at the base colour col = mix(c0, c1, st(l, 0.30)); col = mix(col, uBase, st(l, 0.50)); col = mix(col, c3, st(l, 0.91)); if (uKind == 5) col *= 0.965 + 0.07 * vnoise(vObj * 22.0); // mottled leather } else if (uKind == 1) { // skin: pale, nearly unshaded from the front vec3 sL = vec3(0.973, 0.882, 0.835), sS = vec3(0.776, 0.659, 0.608), sC = vec3(0.451, 0.322, 0.310); if (uSkinRamp == 1) { sL = uSkinL; sS = uSkinS; sC = uSkinC; } // the character's tone, designed per tone col = mix(sC, sS, st(lit, 0.20)); col = mix(col, sL, st(lit, 0.38)); if (uFaceTex == 1) col *= clamp(tex.rgb / uSkinRef, 0.55, 1.05); if (uSkinRamp == 0) col *= uSkinTone; // older builds: one tint over the light skin } else if (uKind == 2) { // hair: four even bands, hard strand highlights float strand = dot(tex.rgb, vec3(1.0 / 3.0)); vec3 c0 = tone(uBase, 0.72, 0.07, 1.10, 0.45), c1 = tone(uBase, 0.72, 0.035, 1.05, 0.68); vec3 c2 = tone(uBase, 0.56, 0.02, 0.92, 1.10), c3 = tone(uBase, 0.56, 0.03, 0.55, 1.42); float l = lit + (strand - 0.6) * 0.12; col = mix(c0, c1, st(l, 0.40)); col = mix(col, uBase, st(l, 0.56)); col = mix(col, c2, st(l, 0.84)); float sp = dot(n, normalize(uKey + v)) + (strand - 0.6) * 0.18; col = mix(col, c3, st(sp, 0.90) * (1.0 - st(sp, 0.985))); } else if (uKind == 3 || uKind == 4) { // steel, gold bool plate = vPlate.x > 0.5; float e = vPlate.y, w = vPlate.z; bool gilt = uKind == 4 || (plate && w > 0.0 && e < w); // an outline plate's gold border col = metal(gilt, gilt && uKind == 3 ? uGold : uBase, n, v, lit, plate ? vPlate.w : -1.0); if (plate && w > 0.0) { // engraved lines: one inside the border, one on its seam float aa = fwidth(e) * 1.2 + 1e-5; float l1 = 1.0 - smoothstep(0.0010, 0.0010 + aa, abs(e - (w + 0.0050))); float l2 = 1.0 - smoothstep(0.0006, 0.0006 + aa, abs(e - w)); col = mix(col, col * 0.42, max(l1, l2 * 0.8)); } } else if (uKind == 7) { // iris float l = dot(tex.rgb, vec3(1.0 / 3.0)); col = l < 0.3 ? mix(uEye[0], uEye[1], l / 0.3) : mix(uEye[1], uEye[2], clamp((l - 0.3) / 0.6, 0.0, 1.0)); } else if (uKind == 8) { col = tex.rgb; } else { col = uFlat; } if (vPlate.x > 0.5 && vPlate.z > 0.0 && uKind != 3 && uKind != 4) { // a cloth outline piece's gold border, float e = vPlate.y, w = vPlate.z, aa = fwidth(e) * 1.2 + 1e-5; // and a fine gold line inside it float band = 1.0 - smoothstep(w - aa, w, e); float l1 = 1.0 - smoothstep(0.0006, 0.0006 + aa, abs(e - (w + 0.0040))); col = mix(col, metal(true, uGold, n, v, lit, -1.0), max(band, l1 * 0.9)); } if (uDecal == 1) { // a design in the piece's own coordinates: vec3 dm = tex.rgb; // R paint, G groove, B gold inlay float pm = smoothstep(0.3, 0.7, dm.r), gm = smoothstep(0.3, 0.7, dm.g), au = smoothstep(0.3, 0.7, dm.b); if (au > 0.0) col = mix(col, metal(true, uGold, n, v, lit, -1.0), au); if (pm > 0.0) { vec3 pc = mix(tone(uMotif, 0.70, 0.05, 1.10, 0.62), uMotif, st(lit, 0.42)); col = mix(col, mix(pc, tone(uMotif, 0.56, 0.03, 0.8, 1.25), st(lit, 0.92)), pm); } if (gm > 0.0) col = mix(col, col * 0.30, gm); } if (vEdge > 0.5) { // the cloth's border band vec3 gd = tone(uBand, 0.20, 0.03, 1.0, 0.72); col = mix(gd, uBand, st(lit, 0.45)); col = mix(col, tone(uBand, 0.16, 0.03, 0.5, 1.45), st(lit, 0.85)); } if (uKind <= 6) { vec3 grade = mix(uAmbient, uKeyCol, st(lit, 0.55)); col *= uKind == 1 && uSkinRamp == 1 ? mix(vec3(1.0), grade, 0.3) : grade; // a designed skin ramp takes less of it float fr = pow(1.0 - max(dot(n, v), 0.0), 4.0); float back = clamp(dot(n, normalize(vec3(0.55, 0.45, -0.35))), 0.0, 1.0); col += uRimCol * fr * smoothstep(0.1, 0.5, back); if (uKind != 1) col *= 1.0 + (g2 - 0.5) * 0.06; } gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); }`; const PAINT_COMP = ` uniform sampler2D tColor; uniform sampler2D tInfo; uniform sampler2D tDepth; uniform vec2 uRes; uniform float uNear; uniform float uFar; uniform float uGap; uniform float uSil; uniform float uInner; ${HSV} float z(vec2 p) { return uNear + texture2D(tDepth, p).x * (uFar - uNear); } int rid(vec4 i) { return int(i.r * 255.0 + 0.5); } float pid(vec4 i) { return floor(i.g * 255.0 + 0.5) + floor(i.b * 255.0 + 0.5) * 256.0; } vec3 ink(vec3 c) { vec3 h = rgb2hsv(c); return hsv2rgb(vec3(h.x, clamp(h.y, 0.20, 0.45), 0.19)); } void main() { vec2 uv = gl_FragCoord.xy / uRes, px = 1.0 / uRes; vec4 inf = texture2D(tInfo, uv); if (inf.a < 0.5) { // outer line: grow the silhouette outward vec4 best = vec4(0.0); vec2 bq = uv; for (int k = 0; k < 12; k++) { float a = float(k) * 0.5236; for (int s = 1; s <= 2; s++) { vec2 q = uv + vec2(cos(a), sin(a)) * uSil * float(s) * 0.5 * px; vec4 ni = texture2D(tInfo, q); if (ni.a > 0.5) { best = ni; bq = q; } } } if (best.a > 0.5) { int r = rid(best); if (r >= 4 && r <= 7) { gl_FragColor = vec4(0.0); return; } gl_FragColor = vec4(ink(texture2D(tColor, bq).rgb), 1.0); return; } gl_FragColor = vec4(0.0); return; } vec3 col = texture2D(tColor, uv).rgb; int r0 = rid(inf); float p0 = pid(inf); float z0 = z(uv); bool feature = r0 >= 4 && r0 <= 9; if (!feature) { for (int k = 0; k < 4; k++) { vec2 d = k == 0 ? vec2(1.0, 0.0) : (k == 1 ? vec2(-1.0, 0.0) : (k == 2 ? vec2(0.0, -1.0) : vec2(0.0, 1.0))); vec2 q = uv + d * px * uInner; vec4 ni = texture2D(tInfo, q); if (z(q) > z0 + uGap && pid(ni) != p0) { col = ink(col); break; } } float acc = 0.0, rad = uSil * 3.0; for (int k = 0; k < 8; k++) { float a = float(k) * 0.785398 + 0.39; vec2 q = uv + vec2(cos(a), sin(a)) * rad * px; vec4 ni = texture2D(tInfo, q); if (ni.a > 0.5) acc += clamp((z0 - z(q)) / 0.05, 0.0, 1.0); } col *= 1.0 - 0.22 * acc / 8.0; if (r0 == 1) { // the bangs shade the forehead float hair = 0.0; for (int s = 1; s <= 4; s++) { vec4 a = texture2D(tInfo, uv + vec2(0.0, float(s)) * uSil * px); int ra = rid(a); if (a.a > 0.5 && (ra == 3 || ra == 11)) hair = 1.0; } col = mix(col, col * vec3(0.84, 0.74, 0.76), 0.75 * hair); } } gl_FragColor = vec4(col, 1.0); }`; const compCam = new THREE.Camera(); function compPass(fs, uniforms) { const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: `void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`, fragmentShader: fs, transparent: true }); const s = new THREE.Scene(); s.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat)); return { mat, scene: s }; } // ---- the renderer --------------------------------------------------------------------------------------------------- const stageEl = $("stage"); const R = new THREE.WebGLRenderer({ antialias: false, alpha: true, preserveDrawingBuffer: !!SHEET }); R.setClearColor(0x000000, 0); R.outputColorSpace = THREE.LinearSRGBColorSpace; stageEl.appendChild(R.domElement); const paint = compPass(PAINT_COMP, { tColor: { value: null }, tInfo: { value: null }, tDepth: { value: null }, uRes: { value: new THREE.Vector2() }, uNear: { value: 0.1 }, uFar: { value: 40 }, uGap: { value: 0.012 }, uSil: { value: 2 }, uInner: { value: 1 } }); const ctl = new OrbitControls(camera, R.domElement);
