CRAFT_TEXTURE_LOOKDEV_GROOM.md

pipelines/CRAFT_TEXTURE_LOOKDEV_GROOM.md

CRAFT — TEXTURE, LOOKDEV AND GROOM

Proposal-tier craft research. Scope: UE5 character lookdev as studios actually practise it — skin

shading (subsurface profile, dual-lobe specular, the meso/micro normal stack), texture bake

standards (edge padding, gutter, mip policy, virtual-texture and UDIM behaviour), the UE5 groom

pipeline (Alembic schema, binding assets, LOD ladders), lookdev calibration scenes (grey ball,

chrome ball, ColorChecker), and the turntable/capture standards studios use to review a character.

Written against two named traps this factory has already hit: a silent grey substitute (a

material that compiles, assigns, and is replaced by the engine default at draw time —

GAME:Tools/char/import_char_0001_headasm.py L251-254) and mip bleed (padding treated as a

render-time nicety rather than a bake-time invariant). Groom bind is pending; the groom half of

this doc is written to be consumed before the bind rather than after it.

This doc does not restate docs/LOOKDEV_STANDARD.md. That standard already owns the HDRI set, the

three calibration objects, the de-light mandate and the 30-240 sRGB albedo band, and it is treated

here as the incumbent. What follows is the practice around it that the standard does not yet

cover, plus the deviations already visible in the named stack.

---

1. SOURCES

Everything below was read for this doc. First-party engine documentation is separated from

practitioner and research material because they carry different authority: Epic's docs state what

the engine does, practitioners state what survives a production.

1.1 Engine and DCC first-party documentation

https://dev.epicgames.com/documentation/en-us/unreal-engine/subsurface-profile-shading-model-in-unreal-engine

breakdown — still the most complete first-party character-lookdev document Epic has published).

https://dev.epicgames.com/documentation/en-us/unreal-engine/digital-humans?application_version=4.27

https://dev.epicgames.com/documentation/en-us/unreal-engine/streaming-virtual-texturing-in-unreal-engine

https://dev.epicgames.com/documentation/en-us/unreal-engine/virtual-texturing-in-unreal-engine

https://dev.epicgames.com/documentation/en-us/unreal-engine/using-alembic-for-grooms-in-unreal-engine

https://dev.epicgames.com/documentation/unreal-engine/importing-grooms-into-unreal-engine

https://dev.epicgames.com/documentation/unreal-engine/groom-components-and-assets-in-unreal-engine

https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-bindings-for-grooms-in-unreal-engine

claims in §2.5 (strands/cards/mesh representation, Binding Type Rigid vs Skinning, Curve and

Vertex Decimation) belong to this page, not to *Groom Components and Assets* where they were

originally implied. URL resolves; the body is JS-rendered and was not machine-readable in this

audit, so those specific LOD claims are first-party-attributed but not re-verified at source.

https://dev.epicgames.com/documentation/en-us/unreal-engine/level-of-detail-for-grooms-in-unreal-engine

https://dev.epicgames.com/documentation/en-us/unreal-engine/hair-simulation-and-rendering-quick-start-guide-in-unreal-engine

https://dev.epicgames.com/documentation/unreal-engine/shading-models-in-unreal-engine

first-party wording for Fuzz Color, the Cloth mask input, the Anisotropy range and platform gate,

and the statement that the anisotropic GGX path runs on Clear Coat and Default Lit — which is what

excludes it from the Cloth shading model.

https://dev.epicgames.com/documentation/en-us/unreal-engine/material-inputs-in-unreal-engine

FuzzyShading function's description and its CoreDarkness / Power / EdgeBrightness inputs — the

Default Lit alternative to the Cloth shading model.

https://dev.epicgames.com/documentation/en-us/unreal-engine/shading-material-functions-in-unreal-engine

the Slab's Fuzz Amount / Fuzz Color / Fuzz Roughness parameters, Epic's note that they are

"usually used to create fabric materials", and the enabled-by-default-for-new-projects status that

keeps Substrate off this factory's critical path for now.

https://dev.epicgames.com/documentation/en-us/unreal-engine/overview-of-substrate-materials-in-unreal-engine

https://helpx.adobe.com/substance-3d-painter/technical-support/workflow-issues/export-issues/texture-dilation-or-padding.html

https://helpx.adobe.com/substance-3d-bake/bakers-settings/common-parameters.html

padding for final bakes). https://marmoset.co/posts/toolbag-baking-tutorial/

1.2 Talks, papers and research

reference talk for real-time skin: separable subsurface scattering, dual specular lobes, eye

shading. GDC Vault: https://www.gdcvault.com/play/1018270/Next-Generation-Character — slide deck

and video mirror: https://archive.org/details/GDC2013Jimenez — author's index:

https://www.iryoku.com/stare-into-the-future/

corrected 2026-08-08 against the author's own talks index). FaceWorks is the middleware library

NVIDIA shipped to put high-end skin and eye rendering into third-party engines; the deck is where

the two-lobe specular approximation of measured skin is laid out for real time.

Author's talks index (verified): https://www.reedbeta.com/talks/

of the modern diffusion-profile + GGX-specular skin model).

https://shihchinw.github.io/2015/12/realistic-human-skin-with-normalized-diffusion-ggx.html

the dielectric albedo band every studio now quotes.

https://seblagarde.wordpress.com/2014/04/14/dontnod-physically-based-rendering-chart-for-unreal-engine-4/

*PBR: From Rules to Measurements*. The measurement method behind albedo calibration from

photography. Author name corrected 2026-08-08 — an earlier draft credited these to "Sebastian

Zapata," who did not write them. Note also that Geupel's position is *adversarial* to the standard

albedo floors, not supportive: he argues that validator advice to lift basecolor to a minimum of

sRGB 50 or 30 conflates total albedo with diffuse albedo, and that correctly cross-polarised

basecolor for dark materials legitimately approaches black.

https://www.racoon-artworks.de/blog_PBRshootingandcalibrating.php ·

https://www.racoon-artworks.de/blog_PBRfromrulestomeasurements.php

2026-08-08** for §2.9: the method that derives a roughness correction from the filtered normal's

length, which is how a woven surface's lost micro-normal is repaid as roughness down the mip

chain instead of aliasing. Summary and comparison against the alternatives (LEAN, CLEAN, vMF) in

Stephen Hill's *Specular Showdown in the Wild West*.

https://www.researchgate.net/publication/220494180_Mipmapping_Normal_Maps ·

https://blog.selfshadow.com/2011/07/22/specular-showdown/

cavity-driven Fresnel occlusion and bent-normal AO).

https://github.com/Safemilk/SkinShader

(discussion #14611; an open engine's parameterisation of the same model).

https://github.com/o3de/o3de/discussions/14611

1.3 Practitioner manuals and studio playbooks

https://caveacademy.com/wiki/onset-production/data-acquisition/data-acquisition-training/the-grey-the-chrome-and-the-macbeth-chart/

Lighting Rig 1003* / *Unreal Character Look Dev and Lighting Turntable Project 1001*.

https://caveacademy.com/wiki/post-production-assets/surfacing/look-development-workflow-using-light-kit-1003/ ·

https://caveacademy.com/product/unreal-character-look-dev-and-lighting-turntable-project/

Symptom, Not a Solution*. **UNVERIFIED (2026-08-08): the cglounge.studio host resolves but

returns HTTP 403 to every fetch, including the site root and the journal index, so neither the

article titles nor their contents could be confirmed.** The two positions attributed to it in §2.7

— evaluate under more than one environment, and treat a per-light hack as a material defect — are

retained as *this doc's own argument*, not as sourced quotation, until the pages can be read.

https://cglounge.studio/journal/lookdev-the-technical-guide ·

https://cglounge.studio/journal/light-linking-lookdev-standards

rules. **DEAD LINK (verified 2026-08-08: wiki.polycount.com refuses connections — the wiki host

is down, not merely bot-blocked).** The padding rules attributed to it below (4 px at 512 scaling

by resolution; gutter ≈ 2× padding) are widely-held community convention that this audit could

not re-verify at source, so they are convention-tier here, not citation-tier — the Substance,

Marmoset and Epic entries are the load-bearing citations for §2.2.

http://wiki.polycount.com/wiki/Edge_padding · http://wiki.polycount.com/wiki/Texture_Baking

texture sets, texel density — analysis from A to Z*.

https://polycount.com/discussion/112758/edge-padding-for-game-characters ·

https://polycount.com/discussion/210017/is-texture-padding-important ·

https://polycount.com/discussion/236654/udim-texture-sets-texel-density-what-is-it-and-why-is-it-analysis-from-a-to-z

(the reference table most character teams work from).

https://static1.squarespace.com/static/5d5eb34eac3f110001ae71de/t/61fbc17457c3c3465fe8edf4/1643889045474/clinton_crumpler-abf6b83b3b65-Texel_Density.pdf

the per-perspective framing**, not a per-class band table — see the attribution correction in §2.3.

https://www.beyondextent.com/deep-dives/deepdive-texeldensity

fullest public account of an actual UE skin material graph).

https://texturing.xyz/pages/saurabh-jethani-creating-realistic-skin-in-ue4

state that displacement ships as **three levels — secondary, tertiary and micro — packed into the

RGB channels of one texture; the page does not** publish which level sits in which channel, so

no channel order is claimed here.

https://texturing.xyz/pages/discover-unwrapped-multi-channel-faces ·

https://texturing.xyz/pages/discover-microskin

https://texturing.xyz/pages/making-a-digital-face-sefki-ibrahim

binding, skin cache and LOD mismatch). https://dawnarc.com/2024/03/ue5groom-hair-notes/

Corruption*. https://dredyson.com/the-hidden-truth-about-metahuman-hair-not-animating-in-unreal-engine-5-what-nobody-talks-about-a-complete-insiders-step-by-step-fix-guide-for-groom-binding-tick-groups-and-level-corrupt/

The two most-cited statements of the loud-failure convention (magenta/black checker, never a

neutral grey). https://developer.valvesoftware.com/wiki/Missing_content ·

https://minecraft.wiki/w/Missing_textures_and_models

same review discipline). https://80.lv/articles/using-unity-lookdev-studio-for-character-art-development

the fullest public account of a scanned-fabric material pass — eight-angle capture into a

multi-angle normal solve, roughness picked by comparison against reference and modulated by a

curvature map, and the explicit reason this practitioner chose FuzzyShading inside a subsurface

material over the Cloth shading model.

https://80.lv/articles/001agt-working-on-the-fabric-materials-for-ue4

---

2. THE PRACTICE, DOMAIN BY DOMAIN

2.1 Skin shading: what the model actually is

The real-time skin model every AAA character team runs is three separable things stacked, and

teams get into trouble by conflating them.

Scattering. UE's Subsurface Profile shading model. Base Color is authored as the colour "as

if viewed from a large distance" — that is Epic's own wording, and it is the reason a plate-derived

albedo with baked shading in it can never be corrected by profile tuning. Human skin's scatter

distance is approximately 1.2 cm; the profile's Scatter Radius is in world units and Epic's docs

warn that a large screen-to-scatter-radius ratio bands under extreme lighting. The 1.2 cm figure is

Epic's, as is the falloff guidance — the doc's exact wording is "avoid using a vivid color here if

you want to get a more complex shading variation." A 0.95 scatter radius with a near-white

subsurface colour and a desaturated peach/red falloff circulates as a starting point for a

real-sized head, but it is NOT in Epic's documentation and no source in §1 carries it (flagged

2026-08-08); treat it as a dial to find by eye, not as a cited number. The newer Burley path replaces radius with **Surface Albedo

Transmission block (Extinction Scale, Normal Scale, Scattering Distribution, IOR, Transmission Tint

Color). Metallic is unavailable on this shading model — the GBuffer channel is repurposed for

profile data. Console levers: r.SSS.Scale, r.SSS.SampleSet, r.SSS.Checkerboard. The model

does not work in the non-deferred/mobile path.

Specular. Skin is not one lobe. Measured skin specular is approximated well by **two specular

lobes** — the two-lobe result predates both talks in this doc's source set, and both productionise

it: Jimenez's GDC 2013 talk for a shipping game, Reed's GTC 2014 FaceWorks deck for reusable

middleware. (An earlier draft of this doc reversed the order, dating Reed to GDC 2013 and having

Jimenez productionise his finding; Reed's talk is a year *later*.) UE exposes this in

the Subsurface Profile asset's Dual Specular block as Roughness 0 (the softer, broader lobe),

Roughness 1 (the tighter lobe) and Lobe Mix. Epic's stated purpose is "subpixel

micro-frequencies" — the tight lobe is what reads as oily skin at portrait distance; without it,

skin reads as painted plastic no matter how good the albedo is. Specular constant sits at UE's

default 0.5 and is modulated by a painted specular map, not replaced by one; the Digital Humans

breakdown notes tighter areas boosted and pores dampened.

Micro-geometry. The normal stack is layered, not singular: meso normals (wrinkles, primary

features, blended per FACS pose in a digital-human rig) plus micro normals (pore-scale, either

scanned or extracted from a specular/displacement channel). Texturing.xyz's convention packs

displacement for **three levels — secondary, tertiary and micro — into the RGB channels of a single

texture**, and the micro level is what becomes the tiling micro-normal. (The cited page states the

three levels and the packing; it does not publish the channel order, so an earlier "R secondary,

G tertiary, B micro" assertion here was over-specific and has been removed — read the order off the

actual purchased map, do not assume it.) A cavity map, generated from displacement, acts as *specular

occlusion*, reduced at Fresnel edges; its job is to stop light raytracing through every pore.

Jethani's UE graph chains detail normals through BlendAngleCorrectedNormals, normalises at the

end, and drives micro-normal application through a painted normal weight mask so pores do not

appear on lips and eyelids.

Resolution. Epic's digital humans run 4K per facial map — diffuse, specular, scatter mask,

meso normal, micro normal, sclera. Roughness is not hand-painted flat; it is derived (Toksvig from

normal) then zone-adjusted. That is the map roster a photoreal head implies, and it is much larger

than basecolor/normal/ORM.

Eyes are a separate shader and they carry disproportionate appeal. Epic's single-surface eye

shader does cornea refraction via IOR without separate cornea geometry, and exposes **Limbus Dark

Scale, Limbus Power, Limbus UV Width Color, Limbus UV Width Shading**, plus Iris Brightness, Iris

UV Radius, Iris Roughness and Iris Concavity Power/Scale, with the iris normal fed through **Clear

Coat Bottom Normal** (r.IrisNormal=1). The surrounding geometry matters as much as the shader:

lacrimal meniscus, caruncle blend mesh, and an eyelid occlusion/blur mesh that seats the eye in the

socket. A head without those three reads dead regardless of iris texture quality.

2.2 Texture bake standards: padding, gutter, mips

The rules here are old, stable and near-universal.

each UV island. Gutter is the empty space *between* islands. The long-standing community rule:

gutter should be roughly twice the padding width, so two islands' skirts never meet.

*(Convention-tier — the Polycount wiki that published it is offline; see §1.3.)*

512**; that scales to 8 at 1024, 16 at 2048, 32 at 4096. *(Same convention-tier caveat.)* Larger is free — nobody cares what lives

in dead space, so filling it costs nothing but disk.

mip halves the map, so a gap of *g* texels at mip 0 is *g/2^n* at mip *n*: three texels survives

mip 1, is marginal at mip 2 and is gone by mip 3. Once neighbouring islands (or the unwritten

background) average together, you get the classic dark or wrong-coloured seam that appears only at

distance. Marmoset's padding deliberately auto-scales with output resolution for exactly this

reason and offers an "extreme"/infinite fill for finals; Substance Painter's guidance is to work

at low dilation and export with infinite dilation.

is not "no padding" — it is padding with black, which is what produces darkened island rims under

mipping and under any bilinear tap near a seam.

them inside the compressor, which reintroduces bleed that padding just removed. Aligning charts to

the 4×4 grid is the standard mitigation.

colour maps need it; every sampled map does.

at render time), bake at a higher resolution than you ship and downsample, and keep the tangent

basis and green-channel convention identical between baker and engine.

2.3 Texel density: the number that decides whether any of this is visible

The bands commonly worked from are 512 px/m for background, 1024 px/m for gameplay-space

surfaces and 2048 px/m for hero props and first-person items; heroes get the top allocation

because players look at them.

Attribution correction (2026-08-08). An earlier draft credited this three-band table jointly to

the Crumpler texel-density reference and the Beyond Extent deep dive. The **Beyond Extent piece does

not contain those bands** — it deliberately declines a fixed per-class table, gives ~20.48 px/cm

(≈2048 px/m) as a general sufficiency figure, and frames the choice by *camera perspective*

(first-person highest, third-person lower, top-down lowest) with named deviations for first-person

weapons, cinematic assets and inspectable/menu objects. So the bands are conventional practice, and

the source that actually publishes a per-class ratio table is the Crumpler reference; Beyond Extent

supplies the method (how to calculate and check density), not the numbers. The corollary that

matters more than the numbers: resolution alone is not density. A 4K map on a 10 m wall is the

same density as a 1K map on a small crate. A head is roughly 0.2 m across — at 2048 px/m that is a

~410 px band, which is why real character work gives the head its own high-density tile or its own

texture set rather than sharing a body atlas.

2.4 Virtual texturing and UDIM: what SVT actually changes

dimensions must be a power of two.**

This is the single most important fact for a multi-tile character: as one VT asset the tiles share

a page table and filtering can be made coherent; as N separate Texture2D assets each tile has an

independent mip chain and there is no cross-tile filtering at all — which is exactly what produces

a visible ring where two tiles meet on continuous skin.

are adjusted to match. A pipeline that flips V itself must know this or it double-flips.

moiré for want of low mips.

source combine into one stack); minimum two fetches plus math per lookup. Diagnostics:

stat virtualtexturing, stat virtualtexturememory, r.VT.Borders 1.

2.5 Groom: the UE5 pipeline as specified

The schema. UE reads Alembic curves (Alembic::AbcGeom::ICurves) carrying a groom schema

version — groom_version_major = 1, groom_version_minor = 5 at constant scope. All custom

properties must be lowercase, no spaces or special characters, prefixed groom_. The roster:

AttributeTypeScopePurpose
groom_guideint8/16/32constant or uniform0 = strand, 1 = guide curve
groom_group_idint32constant or uniformgroups strands (head hair, brow, lash, beard)
groom_widthfloatconstant / uniform / vertexstrand width; not overwritten on import if present; defaults to 1 cm if absent
groom_root_uvfloat[2]uniformroot UVs, spherical projection
groom_idint32uniformstrand id, debugging
groom_colorfloat[3]vertexper-vertex RGB, defaults black
groom_closest_guidesint32[3]uniformguide interpolation
groom_guide_weightsfloat[3]uniforminterpolation weights

Optional metadata: groom_tool, groom_properties. Grooming itself happens in the DCC; UE does not

groom.

*Table caveat (2026-08-08 audit).* Epic's spec page carries groom_guide, groom_group_id,

groom_root_uv, groom_id, groom_color, groom_closest_guides and groom_guide_weights in its

parameters table, and states the naming rule verbatim — "Property names must be lowercase with no

spaces or special characters. All properties must start with the following prefix: groom_<attr>" —

plus the width behaviour ("if the groom_width attribute is present in your groom, it will not be

overwritten during import") and the 1 cm fallback. But **groom_width is discussed in prose, not

listed in that table with a formal scope**; the constant/uniform/vertex scope in the row above is

inferred from the doc's general statement that width may be stored at any of the three levels. Treat

that one cell as inference, and read the scope back off a real .abc when one exists.

The three assets. A Groom asset (the imported strand data), a Groom Binding asset (the

groom projected onto a specific skeletal mesh) and a Groom Component (the instance). Binding is

created by right-clicking the groom → Create Binding; properties are Groom Binding Type

(Skeletal Mesh or Geometry Cache), Target Skeletal Mesh (mandatory), Source Skeletal Mesh

(optional — the mesh the groom was authored against; this is the fix when the groom's origin does

not match the character's), Num Interpolation Points (RBF samples; 100 or fewer recommended) and

Matching Section.

Why the binding asset is not optional in practice. Epic states plainly that without a binding

the projection is done at runtime, at large GPU cost at startup. Rigid attachment is the only

case that legitimately needs no binding.

Project settings that gate the whole thing. Support Compute Skin Cache must be on in Project

Settings → Rendering → Optimization, and the plugins Groom and Alembic Groom Importer must be

enabled — all three are stated by Epic's Hair Simulation and Rendering Quick Start (verified). The

additional requirement that the skeletal mesh's *own* Skin Cache Usage be Enabled is

practitioner-tier, not first-party: it comes from the troubleshooting notes, not from the Quick

Start, which is silent on per-mesh configuration. The most common "hair binds and then disappears"

report is reported to resolve there — verify it on our own mesh rather than assuming it.

LODs. *(Source: Epic's* Level of Detail for Grooms *page — see the §1.1 note; not re-verified at

source in the 2026-08-08 audit.)* A groom asset's LODs each choose a representation — **strands,

cards, or mesh** — and each

LOD independently sets Binding Type: Rigid (follows an attachment) or Skinning (follows the

skin surface). *Binding data only activates on LODs set to Skinning.* Interpolation exposes **Curve

Decimation and Vertex Decimation**. Component-level overrides include hair width, root scale,

tip scale, shadow density, raytracing radius scale, LOD Bias, stable rasterisation, and scatter

scene lighting for short hair.

The failure list practitioners actually hit. Scale mismatch from Blender (export at scale 100 or

correct it in the import settings); transforms not applied before export, producing an offset groom;

SetForcedLOD() on strands making the groom vanish because the binding does not match the skeletal

mesh LOD (drive the *skeletal mesh's* LOD instead and let the groom follow); "target skeletal mesh

could be missing UVs" as the symptom of an unset Source Skeletal Mesh; simulation silently not

running because the groom dropped to a lower simulation LOD.

Shading. UE's Hair shading model is anisotropic with refraction through strands and multi-strand

scatter, approximating three light paths (R, TT, TRT). Epic's digital-human hair adds a

Translucent-Emissive fuzz pass — not the Hair model — and uses Pixel Depth Offset to soften the

hairline where cards meet scalp.

2.6 Lookdev calibration scenes: the three objects and what each one proves

environment's exposure — two sheets are only comparable when this number is. It also makes light

direction, intensity and shadow readable at a glance. Note the honest caveat the CAVE Academy

material records: paint targets 0.18 while the Macbeth neutral patch measures nearer 0.191, so the

ball and the chart are not the same reference.

reduced to a number, because what it carries is a picture of the environment. On set it is shot

with bracketed exposures precisely so the sun is not clipped.

out. CAVE Academy is explicit that it is *not* a colour-correction tool — it is an exposure and

white-balance consistency check. The standard test is that **one linear scale per channel, fitted

on the neutral patches, explains all 24**; residual non-linearity after that means a transfer

function is wrong somewhere.

look "evenly lit" (that defeats the purpose), and are kept from casting on one another.

camera drags the calibration objects through the light and destroys the one property they exist to

have.

2.7 Turntable and capture standards studios use for review

exposure and a standard key-to-fill ratio (3:1 or 4:1 is the commonly quoted band) is where

approval happens; hard sun, flat overcast and a warm low-light interior are where failures

surface. Put bluntly: there is no excuse for approving lookdev under a single environment. *(This

is the doc's own position — the CG Lounge article it was attributed to could not be read; see

§1.3.)*

object and whose second half holds the object still and rotates the HDRI. Rotating the *light*

separately is what exposes baked-in lighting, anisotropy errors and view-dependent artefacts that

a subject-only spin hides. **The principle is what this doc adopts (R-10); the often-quoted

"200 frames, 100 + 100" figure is convention with no verified source in §1 — do not treat the

frame count as a standard.**

that arrives without them is returned before the asset is looked at.

count, triangle count. A measurement pass runs a neutral transform (Standard / no look / exposure

0) precisely because a display look like AgX deliberately does not round-trip a known sRGB value;

a beauty pass under a display look is a *different artefact* and must say so.

instrument is the asset in the shipping renderer at the LOD and material the player will meet.

environment, the material is wrong — a standard worth keeping refuses the hack rather than the

finding. *(Doc's own position; the CG Lounge article of that name is unverified — see §1.3.)*

2.8 The loud-failure doctrine

The single most transferable convention in this entire domain: **a missing or unresolved texture

must render as high-contrast magenta or a magenta/black checker, never as a neutral grey or

white.** Source engine's black-and-magenta checker and Minecraft's #000000/#f800f8 checker are

both explicit about the reason — magenta effectively never occurs in a legitimate scene, so a

missing map is impossible to overlook during QA, whereas grey passes review because grey looks like

a material.

The same doctrine generalises past textures: any substitution the engine performs silently

(a default material at draw time, an unmatched parameter, an unbound groom) is a defect class that

must be made *visible or fatal*, because every check upstream of draw time can pass while the frame

is wrong.

2.9 Cloth shading: woven fabric is its own material class

Added 2026-08-08. This section is the sibling of §2.1 by subject and belongs beside it; it is

numbered at the tail of §2 so the cross-references the §7 citation audit verified on the same day

keep pointing at the sections they were audited against. It closes a measured absence rather than a

suspected one — a case-insensitive grep of this doc for cloth, fabric, sheen, weave, woven,

anisotrop, cotton, linen and textile returned eleven lines and not one of them was a fabric

authoring specification, recorded at

build/3d/best_a14/records/TROUSERS_SKIN_SEPARATION_DIAGNOSIS.json key

CLOTH_FABRIC_what_a_woven_fabric_material_requires.

The reason cloth needs its own section is the same reason skin does. A woven surface is not a

smooth dielectric with a fabric picture painted on it. Its response is dominated by fibres that

stand off the surface and scatter light back toward the viewer at grazing angles, and by a

directional weave that makes the highlight stretch along the yarn rather than sit round. Neither

behaviour exists in the default lit model, so a fabric authored as base colour over a roughness

constant is not a rough fabric — it is a smooth plastic sheet wearing a photograph of fabric, which

is precisely the artefact the trousers ruling names.

The shading model, and when it applies. UE ships a dedicated Cloth shading model whose stated

purpose is to "mimic the properties of cloth more accurately. This includes a thin 'fuzz' layer

across the cloth surface, better simulating the way light interacts with these types of Materials"

(Epic, *Shading Models in Unreal Engine*). It opens Base Color, Metallic, Specular, Roughness,

Emissive Color, Opacity, Normal, World Position Offset, Fuzz Color, Cloth, Ambient Occlusion and

Pixel Depth Offset. The decision rule between it and Default Lit is not taste:

feather and fur trim, anything whose silhouette edge glows against a dark background.

satin, charmeuse, taffeta, tightly finished silk — because the anisotropy that carries that

highlight is not available on the Cloth model. See the exclusion below.

input for. This is the trade the 80.lv fabric breakdown takes deliberately: Mahon reports using

"the FuzzyShading node within my Subsurface material rather than use the 'Cloth' Shading Model

because it allows me more freedom — 'Cloth' doesn't have Subsurface Color options."

Sheen and fuzz, by the two routes. On the Cloth model, Fuzz Color "enables you to add color to your

Material to simulate shifts in color when light passes through the surface" and the Cloth input

"enables you to control the strength of the Fuzz Color as a mask. A value of 0 indicates no fuzz

color contribution to the Base Color, whereas a value of 1 blends fully over the Base Color" (Epic,

*Material Inputs in Unreal Engine*). Two consequences follow that are easy to get wrong. The Cloth

input is a mask and belongs in a texture, not a constant — a garment with a fibrous hem trim and a

finished silk body has two fuzz populations and one scalar cannot express both. And Fuzz Color is

not the base colour lightened; it is the colour light takes on its way back out of the fibre pile,

so it runs warmer and lighter than the ground for a natural fibre and is the wrong place to fix an

albedo that is already wrong.

On Default Lit the equivalent is the engine's own FuzzyShading material function at

/Engine/Functions/Engine_MaterialFunctions01/Shading/FuzzyShading, documented as a function that

"emulates a surface similar to velvet or moss, and is similar to a Fresnel calculation" (Epic,

*Shading Material Functions in Unreal Engine*). Its inputs are Diffuse, Normal, CoreDarkness

(default 0.8), Power (default 6.0) and EdgeBrightness. The cost of this route is stated plainly:

it is a diffuse-side approximation driven by the view-normal angle, so it brightens the rim and

darkens the core of the shaded colour, and it does not touch the specular lobe at all. It survives

a still frame and a fixed light well; it is weaker than the real model under a moving key light,

and it cannot produce the retroreflective response that makes velvet read as velvet. It is the

correct choice when subsurface or anisotropy is needed on the same material and the fabric is not

a pile fabric, and the wrong choice when the fuzz itself is the subject.

The Substrate path is the third option and the one this factory should watch rather than adopt

today. Substrate's Slab carries fuzz as first-class parameters — Fuzz Amount, which "when greater

than 0, adds a fuzz-like layer at the interface, causing color retroreflectivity"; Fuzz Color,

which "defines the color of the fuzz layer"; and Fuzz Roughness, where "fuzz with a roughness of 0

is smooth (shinier) and 1 is fully rough (matte)" — and Epic's own note on those parameters is that

they are "usually used to create fabric materials" (Epic, *Overview of Substrate Materials*).

Substrate also removes the exclusion below, because a Slab carries fuzz and anisotropy at once.

The reason it is not the ruling here is that Substrate is enabled by default only for newly created

projects and is carried in the documentation with a use-caution-when-shipping advisory; switching

an existing character stack onto it is a project-wide decision, not a garment decision.

The exclusion that actually decides the choice. Anisotropy is documented as working "with supported

Gen5 platforms, and when scalability settings are High, Epic, or Cinematic", using the GGX

anisotropic formulation "with the Clear Coat and Default Lit shading models" (Epic, *Material

Inputs in Unreal Engine*). Cloth is not in that list. So in the non-Substrate path the engine makes

the author choose: a sheen term or a stretched highlight, never both on one material. For the

Humanity hero garment that is a real fork, because the concept sheet carries both a silk-blend

ground that wants the stretched highlight and a feather-fluff hem that wants the halo. The

resolution is not a compromise material — it is two material slots on one mesh, the body panel on

Default Lit with anisotropy and the trim on Cloth with a fuzz mask, which costs one extra draw and

settles the question honestly.

Woven normal authoring, and the frequency that is allowed to exist. A weave normal is not free to

be as fine as the real cloth. The map has a texel pitch, the pitch is set by the asset's measured

texel density, and a weave period that lands near two texels aliases into moiré the moment the

camera moves or the surface mips down. The floor this factory should work to is four texels per

weave period, which puts the finest authorable weave at half the density figure §2.3 already

governs. Run the arithmetic on the asset rather than typing a frequency: at the A14 trousers'

measured 21.71 texels per centimetre the texel pitch is 0.461 mm, four texels is 1.84 mm, and the

finest weave the UDIM-space map can legally carry is about 5.4 cycles per centimetre. A real

silk-blend knit's wale pitch is roughly 0.2 to 0.6 mm. The two numbers do not meet, and that gap is

the whole argument for the split.

So cloth inherits the meso and micro split §2.1 establishes for skin, for the same reason and with

a sharper boundary:

garment at this place — seam relief, the raised thread of an embroidery motif, a hem's rolled

edge, gathered folds at the waistband, the compression where a panel is stitched.

UV multiplier, because only a second, much smaller footprint can resolve a 0.2 mm yarn. Author

the tiling swatch against a declared physical footprint in millimetres and publish that number,

or the weave's world scale is unknowable and the garment reads at the wrong size.

BlendAngleCorrectedNormals and normalise at the end — rather than adding and hoping.

baked map, per §2.2. A weave is the one content class where an inverted green reads as a

plausible fabric rather than as an error, so it will not be caught by eye.

What the micro layer loses to mips must be repaid as roughness. This is not optional book-keeping.

When a high-frequency normal minifies, the averaged normal shortens and the surface's true

lobe-widening is thrown away, which is exactly the specular aliasing that Toksvig's *Mipmapping

Normal Maps* addresses by deriving a roughness correction from the filtered normal's length and

baking it into the roughness mip chain. §2.1 already records that Epic derives skin roughness this

way rather than painting it flat; a woven surface has more high-frequency normal content than skin

does, so the argument is stronger here, not weaker.

Roughness authoring for fabric, and why a constant is the specific defect. Fabric roughness is

spatially varying by construction, at three separable scales, and a single scalar destroys all

three:

compressed, calendered and smoother; the trough holds fibre ends and dust and is rougher. This

variation is what makes cloth read as woven at grazing angles even when the weave normal has

mipped away.

seam allowance is compressed, a knee is abraded. This is the layer a curvature or cavity map

drives, and it is the same modulation author_outfit_pbr.py already applies for the arrival-set

pieces.

thread, a polished cabochon, a feather pile and a silk ground do not share a roughness value and

cannot be made to.

The defect Josh named — "textures looking completely different between the trousers and the skin" —

is the readable symptom of a scalar. The A14 V2.1 trousers wire roughness as a

MaterialExpressionConstant of 0.8 and the skin materials wire one of 0.65

(GAME:Tools/char/import_char_0001_a14_v21.py:174-177, and the diagnosis record's

B_MATERIAL_AND_TEXTURE_INVENTORY). Two constants 0.15 apart, both with zero spatial variation, is

one surface response rendered at two brightnesses. Cloth and skin then differ only in albedo, which

is the one channel the eye discounts fastest under a moving light. The fix is not a different

constant; it is that one of the two surfaces must carry a distribution. The house rule is already

written for this in build/3d/characters/author_outfit_pbr.py:39 — "an ORM whose G is one value is

a scalar roughness wearing a texture's name" — and it should be a refusal for every garment, not a

docstring on one.

Anisotropy, and the fabrics that actually need it. Anisotropy takes a value "between -1.0 and 1.0,

where a value of 0 has no anisotropic effect", and it is meaningless without a Tangent input that

tells the shader which way the yarn runs (Epic, *Material Inputs in Unreal Engine*). The test for

whether a fabric needs it is whether the fabric has a visible grain direction that survives the

finish:

float of yarn lying in one direction, which stretches the highlight across the weave and is the

reason a silk garment shows a bright band that travels as the body turns.

directions in equal measure and reads isotropic at any distance a player will meet it; adding

anisotropy there buys a shader cost and a tangent map for an effect nobody can see.

omnidirectional, so the halo is the effect and the grain is not.

through the UV layout, and a panel rotated in UV space carries a rotated grain unless the map

says otherwise. Authoring anisotropy without publishing the tangent source is how a silk ends up

with its highlight running across the leg on one panel and along it on the next.

ORM packing follows the project's existing convention and does not get a new one. The packing in

force is R ambient occlusion, G roughness, B metallic, stated at

build/3d/characters/author_outfit_pbr.py:28 ("ORM -- R ambient occlusion, G roughness, B

metallic, the same packing the head uses"), matched at

build/3d/characters/author_body_textures.py:25-26 for the body and at

GAME:Tools/char/eye_material_contract.py:221 for the eye ("R=AO G=rough B=0"). The role table

that imports it is GAME:Tools/char/import_char_0001_garment.py:112-114, which pins ORM to

TC_MASKS and non-sRGB. Three properties of that convention are load-bearing and must not drift:

the map is linear and never sRGB, because a gamma curve applied to a roughness value is a different

material; the metallic channel is exact zeros for a non-metal and the strongest statement of that is

a channel whose sum is 0, which is a measurable refusal rather than an assertion; and metallic is

binary in practice, so a metal-wrapped embroidery thread is 1.0 and the cloth beside it is 0.0,

never a ramp between them.

Fit and drape, which no material can rescue. This belongs in the practice law because the failure

is routinely mis-assigned to texture. A material decides how a surface responds to light. It cannot

decide where the surface is. A garment shell that is a uniform offset from the body has no

gathering at the waistband, no tension lines from the hip to the crotch, no relaxation behind the

knee and no volume where the hem stands away from the leg — and no roughness map, sheen term or

weave normal will introduce any of them, because the silhouette against the background carries none

of those signals and the silhouette is the first thing a viewer reads. The corollary matters for

sequencing: when a garment is graded down for looking cheap, measure the shell before authoring

maps, because a photoreal cloth stack on a shrink-wrapped shell spends the whole budget on the one

half of the problem that was not broken. The A14 trousers are the measured example in both

directions — the shell is a clean 1.4 to 3.4 mm offset with every fit tooth passing, and it is

still a shell whose entire material response is one albedo and two constants.

---

3. ACTIONABLE RULES, MAPPED TO OUR STACK

Each rule names the source practice, the file(s) that own it here, and what changes. These are

proposal-tier; nothing below is applied by this doc.

R-01 — Edge padding is a property of the BAKE, not of the renderer

Practice: Polycount edge padding; Substance "export with infinite dilation"; Marmoset padding

auto-scaled to resolution.

Ours: build/3d/finish_a14/bake_plates.py writes img only inside cover (the rasterised UV

island mask) — everything outside an island is left at zero. The dilation that exists lives in

build/3d/finish_a14/render_textured.py behind --pad, whose default is 0, writing derived

_PAD tiles into texture/pad/. Compare build/3d/characters/author_outfit_pbr.py dilate() and

author_outfit_ornament.py, which do dilate across island borders at author time.

Change: move dilation into bake_plates.py as a final, unconditional stage so texture/*.png

is padded on disk; keep render_textured.py --pad as an override, not as the only producer. Record

the realised pad width in BASECOLOR_BAKE_A14.json beside observed_fraction.

R-02 — Pad width is derived from resolution, and it is at least 8 texels at 1024

Practice: 4 px at 512 as the floor, scaling to 8/16/32 at 1024/2048/4096; gutter ≈ 2× padding.

Ours: harness/asset_factory/uv_pack_options.py sets UV_PADDING_TEXELS = 2 at

BAKE_RES_DEFAULT = 1024, measured to realise a 3-texel island gap

(UV_MEASURED_ISLAND_GAP_TEXELS), with padding_for() scaling linearly (4 at 2048).

bake_plates.py bakes at --res default 2048.

Change: the packing gutter and the content dilation are two different numbers and should be

argued separately. The gutter number is defensible as-is *if* the content is dilated into it; the

docstring's own justification ("the filtering the renderer does") is a mip-0/mip-1 argument, and the

mip chain that ships goes further down. Either raise the gutter to the 8-at-1024 band, or state

explicitly which mip level is the deepest the character is ever sampled at and show the arithmetic

that 3 texels survives it. build/3d/uv_padding_evidence/ already has the measurement harness to

settle this — extend measure_uv_padding.bleed_visual past mip 1.

R-03 — Ship the character's UDIM set as ONE virtual texture asset, not N Texture2Ds

Practice: Epic SVT — UDIM sets import as a single VT asset via BaseName.####.ext.

Ours: the atlases are already correctly named (A14_BASECOLOR.1001.png.1021.png) but

build/3d/characters/CHAR_0001_A14_V21_IMPORT_MANIFEST.json lists **one texture entry with its own

ue_name per tile**, and GAME:Tools/char/import_char_0001_garment.py imports each as a separate

Texture2D. The postmortem's own finding that the neck ring is "a UDIM texture discontinuity, NOT a

graft failure" is the predicted symptom of exactly this.

Change: import the set as a UDIM/VT asset and set VirtualTextureStreaming on it; failing that,

accept the discontinuity as known and stop attributing it to geometry. Note the V-flip: UE flips

UDIM tiles on import and adjusts mesh UVs, so partition_and_uv.py's own UV offsetting must be

re-verified against the imported result, not assumed.

R-04 — The skin material must actually be Subsurface Profile

Practice: Epic Digital Humans; Jimenez GDC 2013.

Ours: no character material in GAME:Tools/char/* sets a shading model. dress_flores_look.py

sets MSM_TWO_SIDED_FOLIAGE for foliage, so the API path is known and used elsewhere;

import_char_0001_headasm.py builds a basecolor/normal/spec/metallic graph on the default lit

model. eye_material_contract.py *declares* Subsurface Profile for MI_CHAR_0001_Eye and the head

slot, and records that T_HEAD_SSS is "landed and honestly unused today".

Change: add a shading-model field to the material spec consumed by

import_char_0001_garment.py's SCRIPT, set MSM_SUBSURFACE_PROFILE for skin slots, author a

SubsurfaceProfile asset with Scatter Radius, Falloff Color, Dual Specular Roughness 0 / Roughness

1 / Lobe Mix as named parameters, and read every one back the way the usage flag is already read

back. Metallic must be dropped from skin slots — the channel does not exist on this model.

R-05 — Extend MAP_ROLES to the roster a photoreal head implies

Practice: Epic's 4K per-map roster — diffuse, specular, scatter/thickness mask, meso normal,

micro normal, cavity; roughness derived not painted.

Ours: GAME:Tools/char/import_char_0001_garment.py defines exactly three roles —

("BC","basecolor",True,"TC_DEFAULT"), ("N","normal",False,"TC_NORMALMAP"),

("ORM","orm",False,"TC_MASKS") — and import_char_0001_arrival_set.py reuses that table verbatim.

Its own docstring already records that an earlier version silently dropped normal and ORM, so the

table is the right place to add.

Change: add SSS (scatter/thickness mask), CAV (cavity), MICRON (tiling micro-normal) and

SPEC roles with their compression settings, and keep the existing rule that a role the manifest

does not pin is a BLOCKED problem — that rule is what makes the extension safe.

R-06 — Pin texture settings the importer currently leaves at engine default

Practice: mip generation, texture group and streaming flags decide how a character texture

behaves at distance; they are not defaults to inherit.

Ours: the import script sets srgb, compression_settings and flip_green_channel, and reads

srgb back. It sets no mip_gen_settings, no lod_group, no virtual_texture_streaming, no

never_stream.

Change: pin them in the same want/observed_on_disk readback pattern the file already uses —

TEXTUREGROUP_Character / TEXTUREGROUP_CharacterNormalMap, explicit MipGenSettings, and the VT

flag from R-03. The readback discipline is already there; only the field list is short.

R-07 — Nothing may substitute silently; substitutions are magenta or fatal

Practice: the Source/Minecraft loud-failure convention.

Ours: import_char_0001_headasm.py L251-254 records the canonical incident — a material with no

used_with_skeletal_mesh flag "compiles, assigns, and is silently replaced by the engine default AT

DRAW TIME", caught only by a frame in the running game. eye_material_contract.py was written as

the answer to that class for parameters.

Change: generalise it. A house M_MISSING magenta-checker material and a magenta T_MISSING

texture, assigned by the importer wherever a role does not resolve, plus a capture-time

check that scores magenta pixels in GAME:Tools/char/capture_char_0001_a14.py's frames. A grey

default is the failure mode; magenta in a frame is a fact no reviewer can miss.

R-08 — The groom lands as an Alembic groom with a binding asset, or the LOD ladder is declared as cards on purpose

Practice: Epic groom schema and binding; skin cache prerequisite; per-LOD Binding Type.

Ours: docs/GROOM_STAGE_SPEC.md §3.1 already names IMPORTED-Alembic as one of three legal strand

sources and cites the naming convention, and §3.4 defines a four-rung card LOD ladder. But the

landed asset is SK_CHAR_0001_A14_V21_GROOMED.fbx — hair as geometry inside the skeletal mesh

(build/3d/characters/emit_a14_v21_manifest.py). There is no Groom asset, no Groom Binding asset,

and nothing in the repo enables Support Compute Skin Cache.

Change: decide and record which lane the hero head is on. If cards-in-FBX is the ruling (it is a

defensible budget choice and §3.4 argues it), then say so in GROOM_STAGE_SPEC.md §0 and drop the

Alembic route from the critical path. If strands are wanted at LOD0, then the prerequisites are

concrete and ordered: enable the Groom and Alembic Groom Importer plugins, enable Support Compute

Skin Cache project-wide and Skin Cache Usage on the mesh, export with groom_guide /

groom_group_id / groom_width / groom_root_uv present, create the binding with **Source

Skeletal Mesh** set, and set Binding Type = Skinning on every LOD that should follow skin.

R-09 — Never drive groom LOD directly

Practice: SetForcedLOD() on strands makes the groom vanish when the binding does not match the

skeletal mesh LOD; drive the skeletal mesh and let the groom follow.

Ours: not yet reachable (no groom component), but docs/GROOM_STAGE_SPEC.md §3.4's ladder is

where this belongs before it is built.

Change: write it into the spec's ladder now as a construction rule, so the card ladder and any

future strand ladder are both driven from the body's LOD.

R-10 — Add the second rotation to the turntable

Practice: rotate the subject, then rotate the environment.

Ours: docs/LOOKDEV_STANDARD.md §4 specifies every declared environment × N views, default 8,

produced by GAME:Tools/lookdev/run_lookdev.pybl_lookdev_sheet.py. The standard is explicit

and correct that the rig is a constant and the subject turns.

Change: add an HDRI-rotation strip as a second axis. Rotating the *light* around a static

subject is the pass that exposes baked-in lighting and anisotropy errors, and it is cheap: the

existing rig already holds everything else fixed. This is the one review artefact the standard's

four-environment matrix does not substitute for.

R-11 — Give the head its own texel density and prove it

Practice: 1024 px/m gameplay, 2048 px/m hero; density is px per metre, not map resolution.

Ours: partition_and_uv.py already gives the head tile 1002 "its own texel density" by design,

and the postmortem quantifies the failure it was meant to prevent — three body plates projecting

~15k source pixels onto 2.87M texels, roughly 190×. docs/LOOKDEV_STANDARD.md §8 names a

project-wide texel-density standard as explicitly NOT armed.

Change: emit measured px/m per region into BASECOLOR_BAKE_A14.json alongside

observed_fraction. The bake already has the mesh, the UVs and the world scale; the number is

arithmetic, and it converts "the head is mush" from a judgement into a reading.

R-12 — Score source-pixel sufficiency at bake time, not after review

Practice: photogrammetric albedo work calibrates from evenly-lit, sufficient source; de-lighting

is required and starting from evenly-lit source is a precondition, not a substitute.

Ours: delight_atlases.py correctly makes de-light mandatory and correctly publishes tool

refusals as results. But nothing refuses a bake whose *source* is insufficient — the 190× case ran

to completion and published.

Change: add a source-sufficiency refusal to bake_plates.py: per region, source pixels observed

÷ texels written. Below a declared floor the region REFUSES the same way the doll tile refuses,

with its number in the record. This is the instrument the postmortem's RC2 says nothing owned.

R-13 — The ColorChecker residual is a gate, not a printed number

Practice: one linear scale per channel fitted on neutrals must explain all 24 patches.

Ours: docs/LOOKDEV_STANDARD.md §3 specifies exactly this test and §4 puts the fitted scale and

residuals on the sheet; harness/check_lookdev_albedo.py gates that the *instruments fire*, not

that any asset passes.

Change: promote the E1_NEUTRAL_STUDIO residual to a threshold in harness/gates_config.json for

assets whose textures are repo-resident. The standard's reason for not gating (textures live on the

unversioned D: tier) is sound and should be preserved — the gate applies to what is versioned.

R-14 — Separate the measurement pass from the beauty pass, and label both

Practice: a display look does not round-trip; a beauty frame is a different artefact.

Ours: already ruled and implemented — Cycles, Standard, look None, exposure 0.0, gamma 1.0

(LOOKDEV_STANDARD.md §4), with the honest-tier footer.

Change: none. Carry the rule forward into any UE-side capture: when

GAME:Tools/char/capture_char_0001_a14.py becomes the review instrument, it inherits the same lock

and the same footer, or the two lanes' sheets stop being comparable.

R-15 — Every garment slot declares a FABRIC CLASS, and the class picks the shading model

Practice: §2.9 — Cloth for pile and halo fabrics, Default Lit with anisotropy for satin and

finished silk, and the engine's own exclusion (anisotropic GGX runs on Clear Coat and Default Lit,

not on Cloth) meaning one material cannot carry both.

Ours: no garment material anywhere declares a fabric class or a shading model.

GAME:Tools/char/import_char_0001_a14_v21.py sets blend_mode, two_sided,

opacity_mask_clip_value and used_with_skeletal_mesh and never shading_model, so every garment

lands on MSM_DEFAULT_LIT by omission rather than by ruling — the same G-04 failure the skin

materials have, on a second surface class.

Change: add a fabric_class field to the per-slot material spec in the import manifest with a

closed vocabulary (silk_satin, knit, plain_weave, pile, feather, metal_thread,

gemstone), map each class to a shading model plus its required map roles, and read the realised

shading model back the way the usage flag is already read back. A garment whose concept carries both

a silk ground and a fibrous trim gets two slots, not one compromise material — the exclusion is the

engine's, so the mesh has to answer it.

R-16 — A garment may not ship with a scalar roughness

Practice: §2.9 — fabric roughness varies at the weave, panel and material-zone scales, and a

constant collapses a woven surface into a smooth dielectric. §2.1's derived-not-painted rule is the

same rule one class over.

Ours: the file that already states this rule states it as a docstring on one script.

build/3d/characters/author_outfit_pbr.py:39 — "an ORM whose G is one value is a scalar roughness

wearing a texture's name" — and the same file re-opens every map from disk and fails the run on a

constant channel. Nothing carries that refusal into the engine-side import: the A14 V2.1 trousers

wire MaterialExpressionConstant 0.8 to MP_ROUGHNESS

(GAME:Tools/char/import_char_0001_a14_v21.py:174-175) against the skin's 0.65, and both pass every

gate in the chain.

Change: promote the constant-channel refusal from author_outfit_pbr.py into the import

readback, so a garment material whose roughness resolves to a single value is BLOCKED the way an

unpinned map role is BLOCKED. Publish the realised roughness mean and standard deviation per garment

slot in the import result json, and publish the same two numbers for the adjacent skin slot beside

it — the separation between cloth and skin is a number the record should carry, not an adjective a

reviewer supplies.

---

4. WHAT WE ADOPT

Adopted outright.

R-02).

read back like the usage flag (R-04).

the existing "unpinned role is BLOCKED" rule intact (R-05).

(R-07).

never-force-groom-LOD rule written into the ladder before it is built (R-08, R-09).

shading model read back (R-15).

the cloth-versus-skin roughness separation published as two numbers per adjacent slot pair

(R-16).

in a tiling detail normal at a declared physical footprint, blended through

BlendAngleCorrectedNormals (§2.9).

Adopted with modification.

is correct practice and would close the neck ring, but SVT brings stochastic trilinear noise, a

power-of-two requirement, reactive pop-in and a per-sample cost — real costs for a character that

is on screen constantly. The recommendation is single-VT for the character's skin set; the

alternative (keep N Texture2Ds, accept and *record* the tile-boundary discontinuity as known) is

legitimate and must be stated rather than left implicit.

sRGB (verified quotation); floors of 30 and 50 both circulate in validator guidance.

docs/LOOKDEV_STANDARD.md §6 already law-ifies 30-240 for dielectrics. Keep ours — the

sources bracket it and re-litigating a landed band buys nothing. But record the dissent:

Geupel (Racoon Artworks) argues the floors are an artefact of conflating total with diffuse

albedo, and that a properly cross-polarised dark material may sit *below* any of them. That makes

our 30 floor a de-lighting-quality check, not a physical law — a basecolor clamped up to 30 to

satisfy a gate is a de-light defect being hidden, which is precisely this factory's named wound.

Declined, with reason.

a cinematic digital human and the wrong spend for a 79-node RPG factory before the slice exists.

Micro-normal plus cavity captures most of the portrait-distance gain; the meso stack is a

post-slice item.

(R-10); the frame count is not. Our sheets are phone-first contact sheets per the standing boards

law, and 200 frames is a video artefact for a different review culture. Adopt the second axis as

additional views on the sheet.

documented publicly. Our four-HDRI CC0 set is already declared, md5-pinned and reproducible by

anyone; a purchased kit would trade reproducibility for polish. The books-never-gate rule applies.

§2.9 — it carries Fuzz Amount, Fuzz Color and Fuzz Roughness alongside anisotropy, which dissolves

the one-or-the-other exclusion the legacy models impose. It is declined as a *garment* decision

because it is a project-wide rendering migration carried with a use-caution advisory, and this

factory has an unbuilt skin stack, an unbuilt cloth stack and an exposed mannequin ring to close

first. Revisit it as a project ruling once the slice exists, not as a side effect of a trousers

fix.

Jethani's tutorial explicitly warns that a specular map "seems to break the shader". Derive

roughness and drive occlusion through cavity before authoring a specular map.

---

5. GAP NOTES

Deviations already visible between the named stack and the practice above. The audit phase consumes

this list; nothing here is a fix, and each line names the file and the evidence.

img = np.zeros(...) and assigns only img[cover]; the nearest-valid push is explicitly "over

the COVERED texels only". Everything outside a UV island is black, which is worse than no

padding — it is padding with black. The dilation is downstream in

build/3d/finish_a14/render_textured.py behind --pad, whose default is 0. So the shipped

path is padded only when a caller remembers, and build/3d/finish_a14/texture/*.png (the tiles

the sighting renders read) are unpadded while .../texture/pad/*_PAD.png are the padded

derivatives. The V2.1 manifest does point at texture_ap/pad/*_PAD.png, so the landed asset is

padded — but by luck of manifest authoring, not by construction.

documents padding=2 at 1024 realising a measured 3-texel gap, and

measure_uv_padding.bleed_visual is described as painting a texel white "when its mip-1 colour

differs". Three texels does not survive mip 3. Nothing in the evidence tree

(build/3d/uv_padding_evidence/) measures past mip 1, so the deepest mip the character is

sampled at is unstated and the padding is unverified below mip 1. Practice floor at the bake

resolution actually in use (bake_plates.py --res default 2048) is ~16 texels; padding_for(2048)

returns 4.

carries one entry with its own ue_name per tile (1001/1002/1003/1021), and

GAME:Tools/char/import_char_0001_garment.py creates one Texture2D each. Epic's SVT

documentation is explicit that a BaseName.####.ext set imports as one virtual texture. Four

independent mip chains across a continuous skin surface is the textbook cause of the neck-ring

discontinuity the postmortem already correctly separated from the graft.

shading_model set only in dress_flores_look.py (foliage). import_char_0001_headasm.py and

import_char_0001_garment.py build default-lit graphs with an explicit Metallic constant of 0.0 —

a channel that does not exist on Subsurface Profile. Meanwhile eye_material_contract.py §

shading_models *declares* Subsurface Profile for the head slot and records T_HEAD_SSS as

"landed for the subsurface-profile pass and is honestly unused today". The contract and the

importer disagree, and the importer is what ships.

in either repo. Skin will read as painted plastic at portrait distance regardless of albedo

quality, and the postmortem's "why is this still so far off" cannot be closed by texture work

alone while this is missing.

ORM only, and import_char_0001_arrival_set.py imports that table rather than defining its own.

Epic's digital-human roster is roughly double that. The file's own docstring records that dropping

a role was already a real defect once — the mechanism to catch a *missing* role exists; the

roster it checks against is short.

compression_settings and flip_green_channel and reads srgb back, but sets no

mip_gen_settings, no lod_group, no virtual_texture_streaming, no never_stream. Basecolor

is TC_DEFAULT (DXT1/5) rather than TC_BC7 for a hero character. Mip policy — the thing that

decides whether G-01 and G-02 bite — is entirely at engine default and unrecorded.

lands SK_CHAR_0001_A14_V21_GROOMED.fbx as the with-groom arm of a pair. There is no Groom asset,

no Groom Binding asset, no Support Compute Skin Cache anywhere in the repo, and no .abc in the

character chain. docs/GROOM_STAGE_SPEC.md §3.1 lists Alembic import as a legal strand source and

§0 states nothing in the spec executes yet — so the deviation is *declared*, but the two halves

(spec says three legal sources; stack has one, in a fourth form) have not been reconciled, and the

brief's "groom bind pending" is exactly this gap.

records "the groom wears the AGE-7 keystone" on an age-14 asset, and

build/3d/best_a14/derive_hair_keystone.py plus head_features/render_check.py both record that

the age-14 colour keystone does not exist (docs/GROOM_STAGE_SPEC.md L326-339, status

AWAITING_GRADED_KEYSTONE). The refusal to invent a colour is correct discipline; the consequence

is that any lookdev judgement of this head's hair is judging a placeholder, and no sheet says so

in its header.

environments × N views with the subject turning and the rig fixed — correct, and better specified

than most studio practice. But there is no environment-rotation pass, so the artefact class that

exposes baked-in lighting *within* a single environment is absent. Given that a de-lighting defect

is this factory's named failure (§5 of the same standard), that is the missing instrument closest

to a known wound.

project-wide texel-density standard as explicitly not armed;

build/3d/finish_a14/partition_and_uv.py gives the head its own tile "so the identity surface gets

its own texel density" but emits no px/m figure. bake_plates.py's own comment diagnoses the

resolution problem in prose ("at 6.5 plate pixels per…") without publishing the number as a field

any gate can read.

or missing-material asset. The one recorded silent-substitution incident

(import_char_0001_headasm.py L251-254) was caught by a human looking at a frame, and the

countermeasure adopted was a single explicit flag set plus readback — correct for that flag,

not generalised to the class. Every review artefact this factory produces is therefore still

capable of showing a plausible grey where a map failed to resolve.

parameter contract but is blocked behind the cage verdict; the graded head has no eyes, no ears

and no lashes. Epic's guidance is that the lacrimal meniscus, caruncle blend and eyelid occlusion

meshes are part of the *shading* result, not decoration. The gap is not that eyes are missing — the

exclusion is ruled and recorded — it is that no lookdev artefact carries an honest-tier line saying

the head being judged has no eyes, so a reviewer grades a face against a face.

2026-08-08 with §2.9.)* M_CHAR_0001_A14_V21_TROUSERS carries basecolor only — roughness 0.8 and

metallic 0.0 are MaterialExpressionConstant nodes

(GAME:Tools/char/import_char_0001_a14_v21.py:174-177), no normal is wired, no ORM exists for

tile 1021, and there is no sheen, fuzz, cloth mask or anisotropy term anywhere on the material.

The only normal bake for that tile, build/3d/best_a14/texture_ap/A14_NRM_OBJECT.1021.png, is

OBJECT space and cannot drive a skinned surface. The regression is measurable against our own

precedent: the arrival-set pieces already ship BC + N + ORM each through

GAME:Tools/char/import_char_0001_garment.py:112-114, so the newer hero garment sits below a

floor this factory already meets elsewhere. Evidence:

build/3d/best_a14/records/TROUSERS_SKIN_SEPARATION_DIAGNOSIS.json key

B_MATERIAL_AND_TEXTURE_INVENTORY.

§2.9.)* The split §2.9 and §2.1 both require has no implementation on either side: there is no

tiling micro-normal for skin (G-06 records the missing MICRON role) and none for cloth. At the

A14 trousers' measured 21.71 texels/cm the UDIM-space map cannot carry a weave finer than about

5.4 cycles/cm, and a real silk-blend knit is three to nine times finer than that — so the weave

is not merely unauthored, it is unauthorable in the space currently available to it. Any fabric

work that does not land a tiling swatch with a declared millimetre footprint is authoring a

frequency the asset cannot hold.

---

6. PROVENANCE

Research conducted 2026-08-08 against live first-party documentation (UE 5.8 doc set, UE 4.27

Digital Humans), the named GDC/practitioner sources in §1, and a direct read of the following files

in this repo and the game repo: build/3d/finish_a14/bake_plates.py, delight_atlases.py,

partition_and_uv.py, render_textured.py, uv/A14_PARTITIONED.mtl;

harness/asset_factory/uv_pack_options.py; build/3d/characters/emit_a14_v21_manifest.py,

author_outfit_pbr.py, author_outfit_ornament.py, char_head_textures.py,

CHAR_0001_A14_V21_IMPORT_MANIFEST.json, CHAR_0001_ARRIVAL_SET_V3_IMPORT_MANIFEST.json;

docs/LOOKDEV_STANDARD.md, docs/GROOM_STAGE_SPEC.md,

docs/pipeline_review/POSTMORTEM_LIKENESS_2026-08-08.md; and

GAME:Tools/char/import_char_0001_garment.py, import_char_0001_arrival_set.py,

import_char_0001_headasm.py, import_char_0001_a14_v21.py, eye_material_contract.py,

GAME:Tools/lookdev/ (contents listed).

What this doc could not check: nothing was run in Unreal, so every claim about what the *engine*

does with our assets is documentation-derived, not observed. The texture bytes on the D: working

tier were not opened. No .abc groom exists to test the schema against. The _PAD tile widths were

not measured — only their existence and the --pad default were read.

§2.9 addendum, 2026-08-08. The cloth section, its two rules (R-15, R-16), its two gap notes

(G-14, G-15) and the four §1 source additions were written in a second pass, against live fetches of

Epic's *Shading Models*, *Material Inputs*, *Shading Material Functions* and *Overview of Substrate

Materials* pages, plus the Mahon fabric breakdown and the Toksvig normal-mipmapping reference. Three

of its numbers are measured rather than cited and their derivation is external to this doc: the A14

trousers' texel density of 21.71 texels/cm, the 0.461 mm texel pitch that follows, and the ~5.4

cycles/cm ceiling — all computed from build/3d/best_a14/uv_ap/TROUSERS_BEST_A14_INSEAM5_UV.obj

against the 2048² tile, and published with their method in

build/3d/best_a14/records/TROUSERS_FABRIC_SET.json. The same caveat as above still binds: nothing

was run in Unreal, so the shading-model behaviour described in §2.9 is documentation-derived and has

not been observed on our own assets. Formatting note: §2.9 follows CVD §17.14 (plain prose, no

emphasis asterisks) while the surrounding sections predate that discipline and keep their bold

lead-ins; the structure, depth and citation habit match §2.1 deliberately.

---

7. CITATION AUDIT — 2026-08-08

An independent pass re-fetched the sources in §1 and checked each against what this doc claims of it.

Twenty sources were sampled. The findings below are already applied in place above; this section is

the record, not a to-do list.

7.1 Corrections applied (a source that did not exist as described, or was misattributed)

FaceWorks*, GDC 2013. The talk is *Advanced Skin Shading with NVIDIA FaceWorks*, GTC 2014,

per the author's own talks index at reedbeta.com. Venue and year both corrected.

DNS resolution (host not found), so nothing at that address could be confirmed to be Reed's deck.

Replaced with the author's verified talks index.

FaceWorks; the finding Jimenez's GDC 2013 talk productionised)" — Jimenez (2013) cannot have

productionised a finding presented by Reed in 2014. Rewritten so both talks are described as

independent productionisations of an older result.

Zapata." The articles are by Martin Geupel. Corrected — and his actual position (that the

sRGB 30/50 albedo floors conflate total with diffuse albedo) is now recorded, because the doc had

been citing him *in support of* a band he argues against. §4's albedo paragraph was rewritten to

carry that dissent, which sharpens rather than weakens our own 30-240 rule.

"512 / 1024 / 2048 px/m for background / gameplay / hero" jointly to the Crumpler reference and the

Beyond Extent deep dive. Beyond Extent (by Timothy Dries) deliberately declines a per-class

table: it gives ~20.48 px/cm as a general sufficiency figure and frames the choice by camera

perspective. The bands are now presented as convention plus the Crumpler reference; Beyond Extent

is credited with the method.

G tertiary, B micro." The cited page confirms three levels packed into RGB but **does not publish

which level is in which channel**. The order claim is removed.

connections. Both citations remain listed as dead, and the padding rules attributed to them are

demoted to convention-tier; §2.2's load-bearing citations are now the Substance, Marmoset and Epic

entries.

number. Epic's Subsurface Profile page does not contain it and no §1 source carries it. Retained

as an explicitly unsourced starting dial, flagged as such.

in any verified §1 source. The two-rotation *principle* is kept and still drives R-10; the frame

count is now labelled convention, not standard.

binding claims sit on Epic's *Level of Detail for Grooms* page, now added to §1.1; the per-mesh

Skin Cache Usage requirement is practitioner-tier, absent from Epic's Quick Start, and is now

marked as something to verify on our own mesh. The groom_width table row's scope cell is flagged

as inference — Epic discusses width in prose but does not list it in the parameters table.

7.2 Verified — source exists and supports the claim made

First-party, checked against live pages and in several cases quoted verbatim: Epic's *Subsurface

Profile* (1.2 cm scatter distance; "final color as if the material is viewed from a large distance";

the vivid-falloff warning; Dual Specular's "subpixel micro frequencies"; Metallic's GBuffer channel

repurposed; the Burley parameter set; r.SSS.Scale / SampleSet / Checkerboard), *Streaming

Virtual Texturing* (UDIM sets import as one VT asset via BaseName.####; power-of-two dimensions;

vertical flip on import because UE samples (0,0) top-left; Tile Border default 4; stochastic

trilinear filtering; stacks of up to 8), *Digital Humans* 4.27 (4K facial maps; Toksvig-derived

rather than painted roughness; specular default 0.5 modulated by a map; the full limbus/iris

parameter list; r.IrisNormal=1; lacrimal meniscus, caruncle blend and eyelid occlusion meshes;

translucent-emissive fuzz and Pixel Depth Offset at the hairline), *Using Alembic for Grooms* (schema

1.5; the lowercase groom_ naming rule verbatim; groom_width not overwritten if present; 1 cm

fallback), *Groom Components and Assets* (the binding claim verbatim — "If the Binding Asset is not

specified, the projection is done at runtime, which implies a large GPU cost at statup time"), *Hair

Simulation and Rendering Quick Start* (Groom and Alembic Groom Importer plugins; Support Compute Skin

Cache), and *Setting Up Bindings* (all five binding properties; the ~100-sample recommendation).

Practitioner and research sources verified: the GDC Vault entry for Jimenez and von der Pahlen's

*Next Generation Character Rendering* (GDC 2013) and its archive.org video mirror; Shih-Chin Weng's

normalized-diffusion + GGX skin write-up; github.com/Safemilk/SkinShader, whose description matches

this doc's summary closely ("dual specular lobes, cavity w/ fresnel occlusion, naive bent normal

support + AO"); O3DE discussion #14611, title exact; the CAVE Academy grey/chrome/Macbeth page

(the 0.18-versus-0.191 discrepancy and "SHOULD NOT be used for colour-correction" both confirmed);

Saurabh Jethani's Texturing.xyz skin tutorial (BlendAngleCorrectedNormals, the normal weight mask,

and the verbatim warning that a spec map "seems to break the shader"); the dawnarc UE5 groom notes

(both cited failures confirmed verbatim — SetForcedLOD() on strands making the groom disappear

because the binding does not match the mesh LOD, and "target skeletal mesh could be missing UVs"

resolving to an unset Source Skeletal Mesh); Lagarde's DONTNOD chart (dielectric diffuse range

50-243); the 80.lv Unity LookDev Studio interview; and the Minecraft wiki's missing-texture colours

(#000000 / #f800f8 in Java Edition, exactly as quoted).

7.3 Unverifiable — host resolves but returns 403 to automated fetch

marmoset.co, polycount.com (forum), developer.valvesoftware.com, cglounge.studio and the

Adobe helpx bake page could not be read; the Crumpler PDF exists at the cited URL but exceeded the

fetch size limit, so its contents are unread. Of these, only cglounge.studio was load-bearing —

two §2.7 positions were written as if quoting it. Those are now marked as the doc's own argument.

The rest support claims that are independently corroborated (Substance's dilation guidance by the

Marmoset and Epic entries; the magenta convention by the Minecraft wiki citation, which was verified

exactly).

7.4 Do the ACTIONABLE RULES still follow?

Re-checked R-01 through R-14 against the verified source set. All fourteen survive, and none

depended on a corrected citation for its logic:

in §2.2, which is arithmetic rather than citation, plus verified Substance/Marmoset practice. The

Polycount demotion costs them nothing — but R-02's *specific* "8 at 1024" floor is now

convention-tier, which is fine because R-02 already asks for the arithmetic to be shown rather than

the number to be obeyed.

verified Subsurface Profile and Digital Humans pages.

import_char_0001_headasm.py L251-254; the unread Valve page is corroboration, not foundation.

dawnarc note, whose two specific claims were confirmed verbatim.

"add the axis," and §4 already declined the frame count.

band table demoted to convention, the case for *measuring and publishing our own* number is

stronger than the case for adopting someone else's.

the verified CAVE Academy page and on our own already-landed docs/LOOKDEV_STANDARD.md.

No rule was found to be invented — each traces to either a verified source, first-party

documentation, or a direct read of our own files. The gap notes G-01 through G-13 are observations

about our repository rather than claims about external sources and were out of scope for this audit.

Generated by harness/site/structure_site.py — the URL path is the repo path. review root