---
name: generated-3d-in-web-games
description: Make AI-generated (image-to-3D) characters and buildings look like their concept art inside a three.js / WebGL game - fix fake metalness and baked AO, pick tone mapping, face buildings to the camera, keep attachment sockets through optimization, and host GLB games. Use when generated GLBs look dull, pale, dark or wrongly oriented in-engine.
---

# Generated 3D models in a web game: make them look like the concept

Tested Oct 2026 on a three.js r180 RTS ("Kings Conquest"): Pixal3D/Trellis GLBs from concept art, rigged characters
and buildings. Symptoms the owner reported: "units look dried up and pale", "the town center looks nothing like
the image", "nearly black units". Each cause below was measured, fixed and re-rendered.

## 1. Measure the materials before touching lights
Blender Workbench / "material preview" ignores PBR settings, so the asset sheet looks great while the engine
doesn't. Read the GLB itself:

```python
import json, struct, io
from PIL import Image
b = open('unit.glb', 'rb').read(); n = struct.unpack('<I', b[12:16])[0]
j = json.loads(b[20:20 + n]); binc = b[20 + n + 8:]
def tex(i):
    t = j['textures'][i]; src = t.get('source', t.get('extensions', {}).get('EXT_texture_webp', {}).get('source'))
    bv = j['bufferViews'][j['images'][src]['bufferView']]; o = bv.get('byteOffset', 0)
    return Image.open(io.BytesIO(binc[o:o + bv['byteLength']])).convert('RGB').resize((64, 64))
for m in j['materials']:
    p = m.get('pbrMetallicRoughness', {})
    print('metallicFactor', p.get('metallicFactor', 1), 'occlusion', 'occlusionTexture' in m)
    if 'metallicRoughnessTexture' in p:
        px = list(tex(p['metallicRoughnessTexture']['index']).getdata())
        print('roughness(G) %.2f metallic(B) %.2f' % (sum(q[1] for q in px) / 4096 / 255, sum(q[2] for q in px) / 4096 / 255))
```
What we found: generated **characters** had a metallic channel averaging **0.17–0.46** on cloth and skin, with
`metallicFactor` 1. Generated **buildings** were ~0.01. Every model also had a strong baked `occlusionTexture`.

## 2. Fix the materials (in place, no re-export)
- **Metalness:** set `metallicFactor: 0` on characters and props. Without an environment map, half-metal cloth
  turns dull grey-sepia in three.js. Give real metal (helmets, blades) an env map later if needed.
- **Baked AO:** drop `occlusionTexture`. three.js multiplies it in, which double-darkens creases and makes units
  near-black at RTS distance. The ORM image stays (roughness still uses it).

```python
# fix_materials.py file.glb [...]: edits the JSON chunk only, binary untouched
import json, struct, sys
for path in sys.argv[1:]:
    b = open(path, 'rb').read(); n = struct.unpack('<I', b[12:16])[0]
    j = json.loads(b[20:20 + n]); rest = b[20 + n:]
    for m in j.get('materials', []):
        m.pop('occlusionTexture', None)
        m.setdefault('pbrMetallicRoughness', {})['metallicFactor'] = 0.0
    js = json.dumps(j, separators=(',', ':')).encode(); js += b' ' * ((4 - len(js) % 4) % 4)
    out = b[:12] + struct.pack('<II', len(js), 0x4E4F534A) + js + rest
    open(path, 'wb').write(out[:8] + struct.pack('<I', len(out)) + out[12:])
```
Validate afterwards (`npx @gltf-transform/cli validate`). If you re-export, put this in the pipeline after the
optimize step so it can't regress.

## 3. Tone mapping: ACES washes generated art out
`ACESFilmicToneMapping` at exposure 1.18 desaturated faces and greens. The project's asset-preview page used no
tone mapping and "looked good". `NeutralToneMapping` at ~1.0 kept the concept colors and still handles highlights.
Keep fog far enough that units near the camera aren't tinted.

**Prove it with a look-dev grid**, using the game's exact lights: rows = material (old / fixed), columns = tone
mapping (ACES / Neutral / None). Load three from the game's own `node_modules` through an import map, render each
cell with `preserveDrawingBuffer`, draw into a 2D canvas, then screenshot with headless Chromium (Playwright with
`--use-angle=swiftshader`). Frame by `Box3().setFromObject(model, true)` (skinned bounds), not a guessed camera.
Show the owner the grid. It ends the "is it the asset or the engine?" debate.

## 4. Buildings must face the camera (RTS convention)
Age of Empires-style games use one fixed camera angle, and every building faces it with its front (door, banners)
toward the viewer. Generated buildings come out with an arbitrary front. Ours all had the door on glTF **+X**, and the
game added a per-team yaw (+π/2 for blue), which showed players the back corner.
1. Find each model's front: render front/right/back/left views and note which tile shows the door.
2. Compute one yaw for every team: for camera offset `(cx, cy, cz)` from the target and the front on model +X,
   `rotation.y = atan2(-cz, cx)` points the door straight at the camera (ours: (27,38,34) → −0.899 rad).
   Add about +25° (−0.46 rad here) for the 3/4 look of the concept art (front plus one side).
3. Keep it in one config constant, and keep every new asset on the same front-axis convention.

## 5. Scale and sockets for rigged units
- Normalize by **mesh** bounds, not bone extents (bones skip the top of the head and the feet, about 12% short).
- Attachment points (`socket_hand_r`, `socket_hand_l`, `socket_carry`) as empties parented to bones work in three.js:
  `getObjectByName('socket_hand_r').add(prop)`. Author props in meters and give each socket the scale
  `mesh_height_units / nominal_height_m`. A meter-sized prop with identity transform then comes out real-size after
  the engine scales the unit.
- Carry sockets go at belly/lower-chest height. Chest-bone height puts the load in front of the face.
- **`gltf-transform optimize` deletes empty nodes, even with `--prune false`**, so sockets silently vanish.
  Run the steps separately instead: `resize --width 1024 --height 1024` → `webp` → `meshopt`, then check that the
  socket names exist in the output JSON.

## 6. Hosting the game
- A claude.ai artifact does not serve `.glb`/`.gltf`/`.bin`. A version with models converted to JSON (base64 data
  URIs) loaded locally, but rendered only fallback primitives in the artifact sandbox. Don't host 3D games there.
- A plain static host works: `npx vercel deploy --prod` from the built `dist/` served `.glb` as `model/gltf-binary`.
  Verify with a real headless playthrough on the live URL, not just HTTP 200s.
- Build with a base-relative path (`vite build --base ./`) only when serving from a sub-path, and load assets
  through `import.meta.env.BASE_URL`, never a hardcoded `/assets/...`.

## 7. Checklist before showing the owner
- [ ] metallic channel sampled; characters `metallicFactor` 0; no `occlusionTexture`
- [ ] tone mapping chosen from a look-dev grid under the game's real lights
- [ ] buildings face the camera with one shared yaw; the front axis is documented
- [ ] units normalized by mesh height; sockets present after optimization; props real-size in an attach test
- [ ] screenshot from the actual game camera compared side by side with the concept art
- [ ] placeholder primitives clearly labeled as placeholders (the owner will ask "are these the real assets?")
