# Look development and technical-art standard

Repo: `jcom_v0bb_simulation_design_standards_lookdev`. Status: draft, 2026-10-20. It covers JCOM and Conquer: the three.js browser RTS on phones, and the same assets in Unreal Engine 5.

This standard sets how surfaces, light, the camera image and effects look, and how one master asset becomes a phone asset and an Unreal asset.

- Geometry is out of scope: topology, LOD triangle counts and mesh budgets belong to the sibling repo `design_standards_geometry`.
- Reference sheets belong to `design_standards_blueprints`.
- Unit sizes, facing and joints belong to `design/standards_units`.
- This repo links to those three and does not copy them.

Companion files:

- `lookdev-standard.json`: the same rules in machine-readable form for validators.
- `OPEN-DECISIONS.md`: what the owner still has to pick.
- `SKILLS.md`: the tools and guides, each rated ADOPT, ADAPT, REFERENCE or SKIP.

How to read the tags:

- **[S#]** cites a web source listed at the end.
- **[L#]** cites the live game tree or an estate file.
- **PROPOSED** marks a value with no source. It needs owner approval.
- **MATH** marks a value derived on the page, with its working shown.

---

## 0. The name and the one rule

**Name.** "Look development" (lookdev) is the industry term for deciding how an asset's materials, textures and lighting look under the target renderer. "Technical art" (tech art) is the broader discipline. It adds performance budgets and the pipeline, and it also covers rigging and geometry.

I chose **lookdev** for this repo for two reasons:

- The sibling repos already own geometry and rigging. "Technical art" would overlap them.
- Lookdev names exactly what is left: surfaces, light, the camera image and effects.

The budgets and pipeline in sections 6 and 8 are the tech-art half of the job. They live here because they govern textures and shaders, not meshes. The alternative name `design_standards_techart` is OD-01 in OPEN-DECISIONS.md.

**The one rule: author high, derive low, never the reverse.** Every asset has one master in real metres with lossless textures. Every game and Unreal file is produced from that master by a script. Nobody hand-edits a derived file. Section 8 answers the owner's "start from the biggest?" question.

---

## 1. Glossary: the term for each thing

| We say | Industry term | What it means for us |
|---|---|---|
| The look of things | **Look development (lookdev)** | Materials, textures and light, judged in the target renderer against the canon renders [L13] |
| Making it fast and portable | **Technical art** | Budgets, the pipeline and shaders |
| Realistic materials | **PBR, metal/rough workflow** | Base colour, metallic, roughness, normal and occlusion, as in glTF 2.0 core [S23] |
| Colour of the surface | **Base colour / albedo** | Diffuse colour for non-metals, specular colour for metals, with no light or shadow baked in [S1][S3] |
| Shiny vs dull | **Roughness** | 0 is a mirror, 1 is fully matte. Surface variation lives here [S1] |
| Metal or not | **Metallic (metalness)** | 0 or 1 only. In-between values are allowed only at rust and dust boundaries [S1] |
| Bump detail | **Normal map (tangent space, MikkTSpace)** | Fake small relief. glTF and three.js use the OpenGL convention (green up). Unreal uses DirectX (green down), so the green channel is flipped on import [S23] |
| Shadows in crevices | **Ambient occlusion (AO)**, baked | Stored in the R channel of ORM |
| Three greyscale maps in one file | **Channel packing, ORM** | R occlusion, G roughness, B metallic, the glTF layout [S23]. Unreal's "OcclusionRoughnessMetallic" preset has the same layout |
| sRGB vs linear | **Colour space / transfer function** | Colour maps are sRGB; data maps are linear [S7][S21] |
| Smaller copies of a texture | **Mipmaps (mip chain)** | Add 33% memory and remove shimmer [S7] |
| GPU-compressed textures | **Block compression**: ASTC, ETC2, BC7, BC5 | The texture stays compressed in GPU memory |
| One file that becomes any of those | **KTX2 with Basis Universal** (ETC1S or UASTC) | Transcoded on the device to ASTC, ETC2 or BC [S6] |
| Pixels per metre on the surface | **Texel density** (px/m) | Set per asset class (section 2.6) |
| A strip texture reused across a ship | **Trim sheet** | Tiling strips of panel lines, rails and hatches, mapped by UV |
| Many textures in one | **Texture atlas** | Fewer draw calls [S7]. Not to be confused with the banned "stripe atlases" [L10] |
| Stickers: hull numbers, ensigns | **Decals**: mesh decals here, deferred or DBuffer decals in Unreal | Section 2.5 |
| Player-colour paint | **Team colour mask / tint** | The house rule is a material named `houseTint` [L10] |
| Light from the sky picture | **Image-based lighting (IBL)** | three.js builds it with `PMREMGenerator`. Unreal uses Sky Light and Lumen |
| Squeezing HDR into the screen | **Tone mapping** (ACES, AgX, Khronos PBR Neutral) and **exposure** | Section 3.2 |
| Shadows split by distance | **Cascaded shadow maps (CSM)** | Unreal replaces these with **Virtual Shadow Maps** |
| Real-time global illumination | **Lumen** (UE5) | Not available in a browser |
| Unlimited-detail meshes | **Nanite** (UE5 virtualized geometry) | Opaque and masked materials only [S19] |
| Light baked into a texture | **Lightmap / baked lighting** | Second UV set |
| Image effects after render | **Post-processing**: bloom, SSAO, SSR, FXAA, SMAA, TAA, MSAA | Section 4 |
| Sparks, smoke, splashes | **VFX**: particles, flipbooks (sprite sheets), soft particles | Section 5 |
| Many copies, one draw | **GPU instancing** (`InstancedMesh`, `BatchedMesh`, EXT_mesh_gpu_instancing) [S14] | |
| A flat picture standing in for a far model | **Impostor (billboard)** | Already in quality tiers as `impostorsAfter` [L7] |
| Cost counters | **Draw calls, triangles, texture memory, shader programs (variants)** | Section 6 |
| One master, many outputs | **Asset pipeline / DCC-to-engine pipeline**, **LOD chain**, **bake (high-to-low)** | Section 8 |

---

## 2. Materials and textures

### 2.1 Workflow

- Use glTF 2.0 core metal/rough, with three.js `MeshStandardMaterial` [S23][S5].
- Use `MeshPhysicalMaterial` only where an allowed extension below needs it. It is a heavier shader.
- In Unreal, the same inputs feed one master material, and each asset uses an instance of it.

### 2.2 Real-world values

Everything is meant to be real, so every base colour and roughness is checked against measured tables. Non-metal base colour is **50–240 sRGB** (about 0.03–0.87 linear) [S3]. Nothing in the world is pure black or pure white.

| Material | Base colour (linear 0–1) | Source |
|---|---|---|
| Fresh asphalt, charcoal | 0.02–0.04 | [S1][S3] |
| Worn asphalt | 0.08–0.12 | [S1][S3] |
| Bare soil | 0.13–0.17 | [S1][S3] |
| Green grass | 0.21–0.25 | [S1][S3] |
| Desert sand | 0.36–0.40 | [S1][S3] |
| Fresh concrete (runways, bunkers) | 0.51–0.55 | [S1][S2][S3] |
| Ocean ice, glacier face | 0.50–0.70 | [S1][S3] |
| Fresh snow (HQ roofs and runway ends, painted snow white [L14]) | 0.80–0.90 | [S1][S3] |
| Iron, steel, bare metal (metallic 1) | (0.53–0.56, 0.51–0.57, 0.49–0.58) | [S1][S2] |
| Stainless steel (metallic 1) | (0.669, 0.639, 0.598) | [S2] |
| Aluminium (metallic 1) | (0.913–0.916, 0.921–0.923, 0.924–0.925) | [S1][S2] |
| Titanium (metallic 1) | (0.542, 0.497, 0.449) | [S1] |
| Water | Not a base colour. It gets its look from Fresnel with IOR 1.333 (F0 = 0.02) and depth absorption | [S2]; the live water already uses F0 0.02 [L3] |

Further rules:

- **Naval paint** (haze grey, deck non-skid) and **military paint** are non-metals (metallic 0) even on steel hulls, because the paint is what you see. Values are PROPOSED until a sampled reference sheet exists (OD-07).
- **Metallic is 0 or 1.** Values between are allowed only on painted metal where the paint is worn through to bare metal [S1].
- **Specular stays at the default** (F0 4%, IOR 1.5). Do not author KHR_materials_specular or KHR_materials_ior [S1].
- The source tables disagree on whether their values are sRGB or linear. Unreal's page does not state it [S1]. This standard treats [S1] and [S3] as linear. Validators check base colour in linear after decoding sRGB.

### 2.3 glTF extensions

| Extension | Verdict | Why |
|---|---|---|
| KHR_texture_basisu | **USE (required for game textures)** | KTX2 stays compressed on the GPU, about 4–8x less memory than PNG [S6][S7]. Loader support confirmed [S5] |
| EXT_meshopt_compression (or KHR_meshopt_compression) | **USE** | Already used by the live loader [L6]. Smaller and faster to decode than Draco [S9] |
| KHR_mesh_quantization | **USE** | gltfpack's default. Smaller vertex buffers [S9] |
| KHR_texture_transform | **USE (sparingly)** | gltfpack quantization uses it [S9]. Also handles trim-sheet offset and tiling |
| KHR_materials_emissive_strength | **USE** | For runway lights, screens and engine glow above 1.0 for bloom [S4] |
| EXT_mesh_gpu_instancing | **USE for static props** | Repeated bollards and lights become one draw [S9] (`-mi`) |
| KHR_materials_unlit | **ALLOW (UI-in-world, impostors only)** | Cheapest shader |
| KHR_materials_clearcoat | **ALLOW on desktop and ultra only**; strip for phone | Needs MeshPhysicalMaterial. Worth it only for wet or lacquered hero surfaces. Packs into UASTC [S6] |
| KHR_draco_mesh_compression | **AVOID** | Two decoders for one job. Meshopt is already in the loader [L6] |
| KHR_materials_transmission | **AVOID on phone; ALLOW only for hero glass in Unreal** | In three.js it renders an extra transmission pass of the opaque scene. Use plain alpha blend for canopies and windows. In Unreal, translucency cannot be Nanite [S19] |
| KHR_materials_volume, ior, specular, sheen, iridescence, anisotropy, dispersion | **AVOID** | Heavier physical shader, more shader variants, little visible gain at RTS distance. Sheen is for cloth |
| KHR_materials_variants | **AVOID for now** | Loader support is unconfirmed [S5]. Team colour already uses `houseTint` [L10]. Damage states belong in the rig and state files owned by the units repo |
| KHR_lights_punctual (lights in the file) | **AVOID in game files** | In forward rendering every light costs every lit pixel. Fixtures are emissive plus baked light. Unreal masters may carry them for Lumen |
| EXT_texture_webp / AVIF | **AVOID** | Smaller downloads, but they decode to full RGBA8 in GPU memory. KTX2 is the target |

### 2.4 Texture maps: colour space, packing and format

| Map | Channels | Colour space | Master (lossless) | three.js game (KTX2) | Unreal |
|---|---|---|---|---|---|
| Base colour (+ alpha) | RGB(A) | **sRGB** | 16-bit PNG | **ETC1S** for most, UASTC for hero or close-up [S6] | sRGB on, BC7/BC1 (Default) [S21] |
| Normal | RG (B rebuilt) | **linear** | 16-bit PNG, OpenGL (green up) | **UASTC** [S6] | Normalmap / BC5, flip green [S21] |
| ORM | R=AO, G=rough, B=metal | **linear** | 16-bit PNG | **UASTC** [S6] (channels differ) | Masks, sRGB off [S21] |
| Emissive | RGB | **sRGB** | 16-bit PNG, or EXR for HDR | ETC1S [S6] | sRGB on |
| Height or displacement (master only) | R | linear | 32-bit EXR or 16-bit PNG | not shipped; baked into the normal map | Nanite displacement or tessellation |
| Team mask (if textured) | R | linear | 16-bit PNG | UASTC, or packed into base colour alpha (OD-05) | Mask |

- Roughness sits in G. That is both the glTF layout and the channel mobile compressors keep most precisely [S7][S23].
- Sizes are powers of two, and always multiples of 4 for Basis [S6].

### 2.5 Trim sheets, atlases and decals

- **Trim sheets: USE for large hard-surface assets** (ships, bunkers, hangars, the sea gate). MATH: a 330 m carrier has roughly 50,000 m² of visible surface. A unique unwrap at 64 px/m would need 50,000 × 4,096 ≈ 205 M texels, a 14k texture, which is impossible on a phone. Large assets therefore use tiling materials and trim sheets on UV0 or UV1, plus a small unique texture for AO and dirt. Unreal masters can add a UDIM unique layer (OD-06).
- **Atlases: USE for props, VFX flipbooks and decals**, so they share one material and one draw [S7].
  - The house ban on **stripe atlases, value-noise tiles and 2x2 colour blocks** stands [L10].
  - An atlas must hold real material maps, not synthetic colour bands.
- **Decals: USE mesh decals in the game.** These are thin alpha-tested quads lifted off the surface with polygon offset and textured from one decal atlas. Use them for hull numbers, ensigns and deck markings.
- **Decals: AVOID runtime `DecalGeometry`** for anything numerous, because it builds new geometry per decal.
- In Unreal, the same decal atlas drives DBuffer decals or the mesh decals.
- The decal count per tier is already in the quality rules: 128, 64, 24, 8, 0 [L7].

### 2.6 Texel density per class (px/m)

MATH, from the live camera [L1]:

- Closest RTS zoom is 22 world units, which is 55 m, at a 50° vertical field of view.
- The visible height there is 2 × 55 × tan 25° ≈ 51 m.
- On a 1080 px desktop that is about 21 screen px per metre. A 2160 px 4K screen at the ultra pixel ratio of 2 gets about 42.
- On a phone at the medium tier (pixel ratio 1.25), portrait gives 1065 px over 51 m, about 21 px/m.
- Anything above about 2x screen density never reaches a pixel in the RTS view. The mip chain throws it away, but the download and memory still cost.

| Class | Game (three.js) | Master and Unreal | Status |
|---|---|---|---|
| Units (vehicles, aircraft, infantry) | **64 px/m** (inspect and gallery view may load the 2048 tier) | 512 px/m | MATH + PROPOSED |
| Buildings, props | **64 px/m** unique, with trim and tiling at 128 px/m | 512 px/m | MATH + PROPOSED |
| Ships, large facilities | trim and tiling at **128 px/m**, unique AO and dirt at 8–16 px/m | 512 px/m trim, plus an optional UDIM unique layer | MATH + PROPOSED |
| Terrain | macro 1–4 px/m, plus detail tiling at 12 m per repeat [L7 terrain-look] | 256–1024 px/m material sets | live value + PROPOSED |
| Hero cinematics (Unreal only) | — | 1024 px/m (first-person class [S22]) | REFERENCE |

The ratio of 8x between master and game equals three mip levels. Deriving the game texture is therefore just dropping the top three mips.

### 2.7 Maximum texture size per platform

| | Phone | Tablet | Desktop | Unreal master |
|---|---|---|---|---|
| Per-texture cap (live tiers) | 1024 at medium, 512 at low, 256 at potato [L7] | 1024 | 2048 at ultra [L7] | 4096–8192 (UDIM allowed) |
| Unit textures, house rule | ≤ 1024 [L10] | ≤ 1024 | ≤ 1024, 2048 for inspect | 4096 |
| Total GPU texture memory | **≤ 192 MB** PROPOSED | ≤ 384 MB PROPOSED | ≤ 1024 MB (desktop GPU ceiling 2048 MB total [L7 runtime-budgets]) | not budgeted (virtual textures) |

MATH, memory per 1024² texture with mips:

- RGBA8 PNG: 4 B × 1,048,576 × 1.33 ≈ **5.6 MB**.
- ASTC 4x4 or BC7: 1 B/px, ≈ **1.4 MB**.
- ETC1S transcoded to ETC2 RGB: 0.5 B/px, ≈ **0.7 MB**.

KTX2 therefore gives 4–8 times the texture budget for the same memory.

---

## 3. Lighting

### 3.1 What the live game does [L1][L2][L5][L12]

- The renderer is WebGL with three 0.170.
- Lighting is one sun (`DirectionalLight`) plus a `HemisphereLight`.
- Intensities are art estimates: sun 2.2–2.65, hemisphere 0.85–1.35.
- Tone mapping is ACES Filmic, with exposure 1.05–1.25.
- Shadows are PCF soft.
- The sun is fixed at azimuth 132°, elevation 48° for every map [L2].
- There is **no environment map**. The code itself notes that "very metallic exports have no environment probe in the RTS view" [L5]. Metals reflect nothing and read as dark.

### 3.2 three.js rules

| Item | Rule |
|---|---|
| Light units | Physically based lights are the three.js default since r155 [S12]. Keep relative intensities, but lock the **ratios**: sun to sky, and sun to fixtures per time of day. Store them in `hq-environment.json` and `terrain-look.json`, as the game already does. PROPOSED: a clear-day sun-to-sky irradiance ratio of about 4:1 |
| Environment (IBL) | **USE.** One `PMREMGenerator.fromScene` (or `fromEquirectangular`) of the same sky gradient the water already uses [L3]. Build it once per time-of-day change, not per frame. Set it as `scene.environment` with an `environmentIntensity` ratio. This fixes the black metals [L5] and costs one small cubemap |
| Tone mapping | Keep ACES Filmic until an A/B test on the canon-render scenes. **Candidate: AgX**, because it is Blender 4.x's default view transform, so what artists see on SV3 and SV4 matches the game. It also keeps fire, explosions and runway lights from skewing hue. Khronos PBR Neutral is for product-accurate colour, not cinematic scenes. Options per [S11]. OD-03 |
| Colour management | Working space linear sRGB. `outputColorSpace = SRGBColorSpace`. Colour textures are tagged `SRGBColorSpace`, data textures are not [S11]. When post-processing, end with `OutputPass` |
| Exposure | One exposure value per scene preset, as the live game already has. No auto-exposure in the browser |
| Shadows | One directional shadow, fitted to the **visible** play area. The live game already does this: boot sets world bounds, but every frame `fitShadowFrustum` refits the shadow to `min(camDist, shadowDistance)` [L1][L7]. Keep it. MATH for the reason: a whole 320-tile map is 1,600 m over 2,048 texels, 0.78 m per texel. The ultra radius of 180 world units (360 across, 900 m) gives 0.44 m per texel at 2048. At closest zoom (22 world units, the 25-unit floor, 125 m across) it is about 0.06 m per texel. The live `shadowCache` updates only when the light changes, which follows the static-shadow advice [S15]. PROPOSED: minimum `shadowMapSize` 1024 on phone medium, so a 7 m tank at mid zoom gets at least about 16 shadow texels |
| Cascades (CSM addon) | **AVOID on phone.** It uses `onBeforeCompile`, which blocks WebGPU [S13], and it multiplies shadow passes. Desktop ultra only, if ever |
| Point and spot light shadows | **AVOID.** Point shadows render 6 times [S15] |
| Dynamic lights | Phone: sun plus hemisphere plus environment, nothing else. Muzzle flashes and explosions are emissive VFX, not lights. Desktop: at most 4 short-lived point lights, PROPOSED |
| Baked AO and lightmaps | **USE** baked AO in ORM for every asset. Lightmaps (second UV) only for static interiors such as the E02 under-ice floors and hangars |

### 3.3 Sun from real geography (time of day)

"Everything is math": the sun's position comes from latitude, longitude, date and time with the NOAA solar position algorithm [S25] (or the SunCalc library), not from a hand-picked angle.

MATH, maximum sun elevation at the June solstice is 90° − latitude + 23.44°:

| Site | Latitude | Max sun elevation | Equinox noon |
|---|---|---|---|
| Camp Century, Greenland HQ | 77.17° N | **36.3°** | 12.8° |
| Kharg Island | 29.25° N | 84.2° | 60.8° |
| Diego Garcia | 7.31° S | 90° (overhead, Feb/Oct) | 82.7° |

The live HQ sun at 48° elevation [L2] is higher than the sun ever gets at Camp Century. Real HQ light is low, warm-to-white, with long shadows, and there is polar night from about November to February.

This conflicts with the owner's standing HQ "ideal daylight" rule [L14]. The rule does not say which date. The proposal is OD-04:

- HQ default: June 21, local solar noon, sun elevation 36.3°.
- Missions set their own date and time.

### 3.4 Water and sea

The live water [L3] is a `MeshStandardMaterial` with `onBeforeCompile`:

- Depth fade comes from the terrain height texture.
- Shore foam is drawn in the shader.
- Waves are a normal perturbation.
- The reflection is the sky gradient (horizon to zenith), weighted by a Schlick Fresnel with F0 0.02. That F0 is physically correct for water at IOR 1.333.
- There are no planar reflections, no SSR and no environment cubemap.

Rules:

- **USE:**
  - the analytic sky reflection;
  - depth-based colour, with absorption in the deep colour;
  - shoreline foam from depth;
  - a sun glint from the real sun direction.
- **Next step, USE:** sample the PMREM environment from 3.2 instead of a two-colour gradient, so clouds and the real sky tint show in the water.
- **AVOID on phone:**
  - planar `Reflector` (it renders the scene a second time);
  - SSR;
  - refraction render targets;
  - FFT ocean simulation.
- **Desktop ultra may add** a half-resolution planar reflection of units only (PROPOSED).
- **WebGPU is blocked.** The terrain splat and water shaders use `onBeforeCompile`, which `WebGPURenderer` does not run [L4][S12]. The port is to TSL node materials, estimated in the code at 2–4 days [L4]. Until it is decided (OD-08):
  - no new `onBeforeCompile` shaders;
  - new shader work is written so that it ports to TSL: small pure functions with their uniforms named in the rules JSON.
- **Unreal:** the Water plugin with Single Layer Water material, or a Gerstner and FFT ocean for the open sea; Sky Atmosphere for the sky [REFERENCE, page not fetched].

### 3.5 Unreal lighting

- **Lumen** is used for global illumination and reflections. Walls are at least 10 cm thick so light does not leak. Building shells are modular. Lumen's default scene range is 200 m [S20].
- **Nanite** is used for every opaque and masked mesh. Glass, water and canopies stay non-Nanite because translucent materials are not supported [S19].
- **Virtual Shadow Maps** are used with Nanite [REFERENCE].
- **Sky Atmosphere** is used, with the directional light set to the same real-geography sun as in 3.3 [REFERENCE].
- Turn off "Generate Lightmap UVs" because nothing is baked [S19].
- Use **virtual textures** for large unique assets [S19].

---

## 4. Post-processing

| Effect | Phone | Desktop | Note |
|---|---|---|---|
| Tone mapping + sRGB output | **USE** (renderer, no extra pass) | USE | |
| MSAA via `antialias: true` | USE at medium and above if it holds frame rate; off at low [L7] | USE | Cheapest anti-aliasing on mobile tile GPUs [S15][S18]. With a composer, use a multisampled render target, or the MSAA is lost |
| Bloom | **AVOID** (`postEffects` off at medium and below [L7]) | USE, one merged pass, threshold above 1.0 so only emissive glows | pmndrs `EffectPass` merges effects [S16]. The `UnrealBloomPass` chain in the compare tool [L8] is for review only |
| FXAA, SMAA | AVOID | SMAA only if MSAA is off | Cost more than MSAA on mobile [S15][S18] |
| SSAO, GTAO | **AVOID**; bake AO | AVOID; bake AO | No sourced mobile cost; baked AO is free at runtime |
| SSR | AVOID | AVOID | Water uses environment reflections (3.4) |
| TAA | AVOID | AVOID | Ghosting on fast units, and needs motion vectors. Unreal uses TSR natively |
| Fog | USE linear `Fog`, as live [L1] | USE | Never hides anything inside camera coverage or at HQ [L14] |
| Colour grading LUT | AVOID until there is a reason | ALLOW, one LUT, merged into the tone-map pass | |

The frame budget is in section 6. Merging passes saves 10% or more of frame time on Adreno [S18].

---

## 5. Effects (VFX)

- **Particles:** use `InstancedMesh` or `Points` with one atlas material per effect family.
  - Count caps per tier are already live: 400, 200, 80, 24, 0 [L7].
  - Use additive blending for fire and muzzle flashes; alpha blending for smoke and spray.
- **Flipbooks** (sprite sheets) go in KTX2 atlases. Base colour uses ETC1S; normals, if any, use UASTC.
  - PROPOSED: at most 8×8 frames of 128 px in a 1024 atlas.
- **Overdraw is the phone limit** (fill rate). Keep big translucent cards few and small on screen.
  - PROPOSED: no more than 3 layers of full-screen-equivalent overdraw from VFX on the phone tier.
  - Use soft particles (depth fade) only on desktop, because they need a depth texture.
- **Real physics, not decoration.** Spray, wake and smoke sizes come from real dimensions in metres and real speeds. Examples: the Sea Dragon launch plume [L13]; steam at the sea gate.
- **Unreal:** use Niagara with the same atlases, plus higher-resolution flipbooks from the master.
- **Instancing:** identical units share one instanced draw. The live game already does this in `unitBatches` [L7 client-performance]. Use `BatchedMesh` for varied static props sharing one material [S14].

---

## 6. Performance budgets (mid-range phone, 393×852 class)

| Counter | Phone | Desktop | Source |
|---|---|---|---|
| Frame rate | 30 fps at low and potato, 60 at medium [L7 frameCapByTier] | 60, p95 under 50 ms [L7] | live |
| Draw calls per frame | **≤ 150** target, 300 hard ceiling | ≤ 1,000 | PROPOSED, adapted from Quest at 100–200 [S17]; a phone renders one view, not two |
| Triangles on screen | **≤ 500k** | ≤ 3M | PROPOSED from [S17]. Per-asset triangle budgets are owned by `design_standards_geometry` (the live `classPolyBudgets` [L7]) |
| Triangle size | ≥ 4 px covered on average | — | [S18] |
| Texture memory | **≤ 192 MB** | ≤ 1,024 MB | PROPOSED, MATH in 2.7 |
| Shader programs | **≤ 40** linked programs | ≤ 120 | PROPOSED; every extension and material feature adds variants |
| Lights affecting a pixel | 1 sun + hemisphere + environment | + up to 4 point lights | PROPOSED |
| Post passes | 0 extra (tone mapping in the main pass) | ≤ 2 merged | [S16][S18] |
| Pixel ratio | ≤ 1.25 at medium, 1 at low [L7] | ≤ 2 | live, [S15] |
| Anisotropy | 2 on phone | 8 | [S7][L7] |

**Measurement method:**

1. Read `renderer.info.render.calls`, `.triangles`, `renderer.info.programs.length` and `renderer.info.memory.textures` [S11]. With post-processing, set `autoReset = false` and reset once per frame.
2. Estimate texture memory from width × height × bytes per pixel × 1.33 per texture. WebGL has no GPU-memory counter [L7 runtime-budgets].
3. Run on a real reference phone through Chrome remote debugging or Safari Web Inspector. Hold for 10 s and report p50 and p95 frame time, using the live quality harness [L7].
4. Run the scene on the canon-render camera positions (section 7) so every report uses the same views.
5. The reference phone is OD-09.

---

## 7. Look reference

- The **canon renders** in `castdb/canon/facilities/renders/` are the target look [L13]. Lookdev reviews compare a game screenshot and an Unreal still against the matching render at the same camera.
- Owner corrections apply on top of the renders [L13]:
  - The Blue Aurora column ends at the waterline.
  - Only our own aircraft appear.
  - HQ is in ideal daylight unless a mission says otherwise [L14]. Night and aurora frames (for example the naval portal render) are **mission** looks, not the HQ default.
- Chrome and HUD stay in the house design system: grey accent, glass panels, JSLC tokens. Nothing in the 3D look adds UI colour. Team colours come from the faction palette files, not from this repo.

---

## 8. Multi-target pipeline: one master, mechanical outputs

### 8.1 "Should we start from the biggest?"

**Yes. Start from the highest-fidelity master and derive every smaller version from it.**

1. **Information only flows downhill.** Downsampling a texture, decimating a mesh and baking high detail to a normal map are mechanical and scriptable. Going up, by upscaling or adding detail to a low-poly mesh, means inventing data, which is exactly what "designed to be real" forbids.
2. **Unreal wants the master as it is.** Nanite meshes and 16-bit textures go in nearly unchanged. Only the phone needs heavy derivation.
3. **The owner's "multiple sizes of each asset" is normal and cheap.** A KTX2 already carries every mip. The phone tier is the same file with the top mips dropped, plus the LOD meshes the geometry repo specifies.

Three caveats:

- **"Biggest" means richest, not bloated.** The master is 16-bit lossless, in real metres, with clean UVs and named parts, at the master texel density in 2.6. An 8K texture on a bolt is not a better master; it is waste.
- **Very large reductions need a planned intermediate.** Automatic decimation from a Nanite master (millions of triangles) straight to a 1,500-triangle phone LOD0 often breaks the silhouette. The master therefore includes a **game-res cage** (a mid-poly mesh built for baking), and the phone LODs come from that cage. Its triangle counts are set in `design_standards_geometry`.
- **Some low versions are designed, not derived.** Impostors and NATO counters [L7] are separate deliverables generated from the master by script, not shrunk copies.

### 8.2 What must be anchored so conversion is mechanical

| Anchor | Rule | Why it matters |
|---|---|---|
| **Scale** | 1 unit = 1 metre in the master and in glTF [S23]. The game multiplies by `metreToWorld` 0.4 at load [L9]. Unreal Interchange converts to centimetres | One number per target, never per asset |
| **Up and forward** | Master and glTF: +Y up, forward −Z, ground or waterline at y = 0 (house rule [L10]; glTF's default front is +Z [S23], so the house rule needs the manifest yaw). Unreal: Z up, X forward, converted by the importer | Owned by `design/standards_units` (its OD-01) |
| **Pivots** | Root at the ground-contact centre. Each moving part's pivot sits at its joint, inside its own bounds [L10] | Rigging and sockets survive every LOD |
| **Names** | Node and material names are fixed and survive export: `teamcolor_*` meshes, `houseTint` material [L10], `turret`, `barrel_01`, `rotor_main`, and so on. Run gltfpack with `-kn -km` [S9] | Code finds parts by name |
| **UV layout** | UV0 is the unique, non-overlapping unwrap and the bake target, with UV anisotropy median ≤ 1.5 [L10]. UV1 is optional trim/tiling or lightmap. No more than 2 UV sets. No UDIM in UV0 (OD-06) | glTF and three.js have no UDIM. Bakes need one 0–1 layout |
| **Texture set** | One Substance Painter texture set per material, exported twice from the same project: preset **glTF PBR Metal Roughness** (OpenGL normal, ORM) and preset **Unreal Engine 4 (Packed)** (DirectX normal, OcclusionRoughnessMetallic) [L11]. Painter 12.1.5 may label the presets slightly differently; check them on SV4 | One paint job, two mechanical exports |
| **Bit depth** | Masters 16-bit PNG, EXR for HDR or height. Game copies are derived KTX2. The existing 8-bit 2048 PNG game copies [L11] are an interim step | KTX2 is lossy and never a source |
| **File names** | `<assetId>_<map>_<px>.<ext>`, for example `ddg51_basecolor_4096.png` or `ddg51_orm_1024.ktx2`. None of the banned words: fixed, final, good, new, latest [L11] | Scripts glob by pattern |

### 8.3 The derivation chain

All tools are installed on SV4 unless marked otherwise [L15].

```
MASTER (per asset, versioned)
  Modeler / Blender 4.2 / Cinema 4D  ->  high mesh (Nanite) + game-res cage, metres, named parts
  Substance Painter  ->  16-bit texture sets at master texel density
  Substance Designer / Sampler  ->  tiling materials and trim sheets (.sbsar), rendered by sbsrender
  Photoshop / Illustrator  ->  decal atlas, markings, ensign vectors

  |-- UNREAL: master mesh (FBX/glTF via Interchange) + "Unreal Packed" export, Nanite on,
  |           virtual textures for large assets.  [Unreal not installed: OD-02]
  |
  `-- THREE.JS: Blender headless (blender -b --python): cage -> LOD0/1/2 per geometry repo
               -> bake normal/AO from high to cage (Painter or Blender)
               -> glTF export (metres, -Z)
               -> gltf-transform / gltfpack: resize to class cap, KTX2 (ETC1S colour, UASTC normal+ORM),
                  meshopt, quantize, dedup, prune, instance   [not installed: OD-10]
               -> glTF-Validator + lookdev-standard.json checks  -> registry
```

---

## 9. USE and AVOID

### USE

1. **Metal/rough PBR with measured values**: base colour 50–240 sRGB for non-metals, metallic 0 or 1 [S1][S3]. This is what makes things read as real.
2. **KTX2 / Basis**: ETC1S for colour, UASTC for normal and ORM [S6]. It gives 4–8 times the texture budget (2.7) and replaces the CPU canvas downscale [L6].
3. **ORM channel packing**, with roughness in G [S7][S23]. One linear texture instead of three.
4. **A PMREM environment from the scene's own sky** [S11]. It gives metals and water something to reflect, and it is built once per time of day.
5. **A shadow frustum fitted to the view, as the live game already does** [L1][S15]. It must survive any refactor (MATH, 3.2).
6. **Sun from real latitude, longitude, date and time** [S25]. Everything is math, and HQ has its own light.
7. **Meshopt plus quantization** (already live) [S9][L6].
8. **Trim sheets and mesh decals for ships and facilities**. The MATH in 2.5 shows unique unwraps cannot fit on a phone.
9. **Instancing and BatchedMesh** [S14][L7].
10. **Baked AO in every asset**. It replaces SSAO.
11. **One Substance project, two export presets** (glTF and Unreal) [L11].
12. **Author high, derive low**, with the anchors in 8.2.

### AVOID

1. **Uploading PNG or WebP textures as RGBA8 for the game.** It uses 4–8x the memory of KTX2.
2. **Draco.** Meshopt already covers it.
3. **KHR_materials_transmission, volume, sheen, iridescence, specular and ior on phones.** Extra passes and heavier shaders.
4. **Lights in glTF files, and point or spot shadows** [S15].
5. **SSAO, SSR, TAA, FXAA and SMAA on phones**, and bloom below the high tier [S15][S18][L7].
6. **Planar reflections and FFT ocean on phones.**
7. **New `onBeforeCompile` shaders.** They deepen the WebGPU block [L4][S12].
8. **A shadow camera covering the whole world**: 0.78 m per texel on a 320-tile map. The live runtime avoids it through `fitShadowFrustum` [L1]. Keep it that way.
9. **Hand-picked sun angles**, including the impossible 48° at Camp Century [L2].
10. **Synthetic atlases**: stripes, noise tiles and colour blocks [L10].
11. **Pure black or pure white base colours, and in-between metallic values.**
12. **Editing derived files, or upscaling to make a master.**

---

## 10. Sources

Web sources were checked on 2026-10-20 by a research worker unless marked otherwise:

- **[S1]** Epic, Physically Based Materials. https://dev.epicgames.com/documentation/en-us/unreal-engine/physically-based-materials-in-unreal-engine
- **[S2]** physicallybased.info (A. Palmqvist). https://physicallybased.info/
- **[S3]** S. Lagarde, "Feeding a physical based lighting mode". https://seblagarde.wordpress.com/2011/08/17/feeding-a-physical-based-lighting-mode/
- **[S4]** Khronos glTF extension registry. https://github.com/KhronosGroup/glTF/blob/main/extensions/README.md
- **[S5]** three.js GLTFLoader source. https://github.com/mrdoob/three.js/blob/dev/examples/jsm/loaders/GLTFLoader.js
- **[S6]** Khronos KTX Artist Guide. https://github.com/KhronosGroup/3D-Formats-Guidelines/blob/main/KTXArtistGuide.md
- **[S7]** Android Developers, Textures. https://developer.android.com/games/optimize/textures
- **[S8]** glTF-Transform CLI. https://gltf-transform.dev/cli
- **[S9]** gltfpack README. https://github.com/zeux/meshoptimizer/blob/master/gltf/README.md
- **[S10]** Khronos glTF-Validator. https://github.com/KhronosGroup/glTF-Validator
- **[S11]** three.js WebGLRenderer docs (tone mapping, `info`). https://threejs.org/docs/pages/WebGLRenderer.html. Colour-management manual: https://threejs.org/manual/en/color-management.html (the fetch failed; the content is from search results).
- **[S12]** three.js Migration Guide (r155 lights, r164 node builder). https://github.com/mrdoob/three.js/wiki/Migration-Guide. The WebGPURenderer manual's statement on `onBeforeCompile` comes from search results; the page itself 404'd.
- **[S13]** three.js CSM addon source. https://github.com/mrdoob/three.js/blob/dev/examples/jsm/csm/CSM.js
- **[S14]** three.js BatchedMesh. https://threejs.org/docs/pages/BatchedMesh.html
- **[S15]** Discover three.js, Tips and Tricks. https://discoverthreejs.com/tips-and-tricks/
- **[S16]** pmndrs/postprocessing. https://github.com/pmndrs/postprocessing
- **[S17]** Meta, Device optimization comparison. https://developers.meta.com/horizon/resources/device-optimization-comparison/
- **[S18]** Qualcomm Adreno mobile best practices. https://docs.qualcomm.com/doc/80-78185-2/topic/mobile_best_practices.html
- **[S19]** Epic, Nanite. https://dev.epicgames.com/documentation/en-us/unreal-engine/nanite-virtualized-geometry-in-unreal-engine
- **[S20]** Epic, Lumen technical details. https://dev.epicgames.com/documentation/en-us/unreal-engine/lumen-technical-details-in-unreal-engine
- **[S21]** Epic, Texture compression settings (UE4.27; the UE5 page is not verified). https://docs.unrealengine.com/4.27/RenderingAndGraphics/Textures/TextureCompressionSettings
- **[S22]** Maxon, Texel Density. https://help.maxon.net/c4d/en-us/Content/html/Texel_Density.html
- **[S23]** Khronos glTF 2.0 specification (metres, +Y up, +Z front, metallicRoughness channels B and G, OpenGL normals). https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html. Known from the spec; not re-fetched this session.
- **[S24]** cloudai-x/threejs-skills. https://github.com/cloudai-x/threejs-skills
- **[S25]** NOAA Solar Calculator and its algorithm. https://gml.noaa.gov/grad/solcalc/. Not fetched this session.

Live and estate sources were read on 2026-10-20 and not modified. `GAME` is `M:\z4yin-work-overflow\hermes-share\codex-lanes\asset-split\sol-maps\work\`.

- **[L1]** `GAME skirmish/src/main.ts` 612–641: renderer, hemisphere and sun, with the boot shadow camera at world bounds. At 1765, `fitShadowFrustum` refits it every frame to `graphics.shadowRadius(camDist)`, defined in `quality/graphicsBind.ts:229`. Camera at 1668: `camMin` 22, field of view 50.
- **[L2]** `GAME skirmish/src/terrainfx/look.ts` 170–189 and `data/rules/terrain-look.json`: ACES Filmic, PCFSoft, exposure 1.05, sun azimuth 132° and elevation 48°.
- **[L3]** `GAME skirmish/src/terrainfx/water.ts`: `onBeforeCompile`, sky-gradient reflection, Schlick F0 0.02, depth foam.
- **[L4]** `GAME skirmish/src/compare/main.ts:58`: the WEBGPU_BLOCKED note (three r170, TSL port 2–4 days).
- **[L5]** `GAME skirmish/src/mechanics/coastalSkirmish/art.ts:62`: no environment probe. No `PMREM` or `scene.environment` anywhere in `src`.
- **[L6]** `GAME skirmish/src/render/textureBudget.ts`: CPU canvas downscale that skips compressed textures. MeshoptDecoder is in `models.ts`. There is no KTX2Loader in `src`.
- **[L7]** `GAME data/rules/quality-tiers.json`: tiers, `featureShedOrder`, `classPolyBudgets`, `lodSwitch`, `frameCapByTier`. Also `runtime-budgets.json` and `client-performance.json`.
- **[L8]** `GAME skirmish/src/compare/post.ts`: EffectComposer and UnrealBloomPass (compare tool).
- **[L9]** `GAME data/rules/world-scale.json`: `metreToWorld` 0.4, `metresPerTile` 5.
- **[L10]** `unit-model-contract` skill (`D:\z4yin\.inbox\dolphin4\lanes\design-repo\svc_skills\unit-model-contract\SKILL.md`): −Z, `houseTint`, textures ≤ 1024, no stripe atlases, UV anisotropy.
- **[L11]** `adobe-substance-pipeline` skill: 16-bit masters, 8-bit 2048 game copies, Painter export presets, `sbsrender`, banned file words.
- **[L12]** `GAME data/rules/hq-environment.json`, `campaign-look.json`: art-estimate intensities and exposure.
- **[L13]** Canon renders: `M:\z4yin-work-overflow\hermes-share\codex-lanes\castdb\canon\facilities\renders\` (8 PNGs). Owner ruling of 2026-10-19 that these are the look reference.
- **[L14]** Owner HQ rulings, 2026-10-18: ideal conditions, snow-white roofs and runway ends, cameras see through fog.
- **[L15]** `D:\z4yin\.inbox\dolphin4\lanes\design-repo\TOOLS-SV3-SV4.md`: installed software, 2026-10-20 16:48.
