pipelines/CRAFT_TEXTURE_LOOKDEV_GROOM.md
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.
---
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.
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/
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
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
---
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.
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.
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.
dimensions must be a power of two.**
BaseName.####.ext (MyTexture.1001.png).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.
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:
| Attribute | Type | Scope | Purpose |
|---|---|---|---|
groom_guide | int8/16/32 | constant or uniform | 0 = strand, 1 = guide curve |
groom_group_id | int32 | constant or uniform | groups strands (head hair, brow, lash, beard) |
groom_width | float | constant / uniform / vertex | strand width; not overwritten on import if present; defaults to 1 cm if absent |
groom_root_uv | float[2] | uniform | root UVs, spherical projection |
groom_id | int32 | uniform | strand id, debugging |
groom_color | float[3] | vertex | per-vertex RGB, defaults black |
groom_closest_guides | int32[3] | uniform | guide interpolation |
groom_guide_weights | float[3] | uniform | interpolation 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.
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.
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.)*
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.
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.
---
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.
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.
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.
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.
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.
MAP_ROLES to the roster a photoreal head impliesPractice: 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.
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.
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.
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.
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.
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.py → bl_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.
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.
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.
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.
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.
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.
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.
---
Adopted outright.
R-02).
read back like the usage flag (R-04).
MAP_ROLES withthe existing "unpinned role is BLOCKED" rule intact (R-05).
(R-07).
GROOM_STAGE_SPEC.md, and thenever-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.
---
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.
build/3d/finish_a14/bake_plates.py initialises 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.
harness/asset_factory/uv_pack_options.py 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.
CHAR_0001_A14_V21_IMPORT_MANIFEST.json 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.
GAME:Tools/**/*.py finds 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.
SubsurfaceProfile asset is authored or referencedin 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.
MAP_ROLES is three roles wide. import_char_0001_garment.py L112-114 pins BC / N / 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.
srgb, 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.
build/3d/characters/emit_a14_v21_manifest.py 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.
emit_a14_v21_manifest.pyrecords "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.
docs/LOOKDEV_STANDARD.md §4 specifiesenvironments × 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.
LOOKDEV_STANDARD.md §8 lists aproject-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.
eye_material_contract.py declares theparameter 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.
---
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.
---
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.
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.
slideplayer.com/slide/3504950/ failedDNS 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.
wiki.polycount.com refusesconnections. 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.
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).
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).
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.
MAP_ROLES, pinned texture settings) rest on theverified 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.