docs(design): add character visuals spec and compositor API from workshop
Round 22 workshop output: character-visuals-spec.md (color mesh regions, LOD tiers, layered composition) and compositor-api-spec.md (Node3D architecture, CharacterColors data structure, set_color API). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
---
|
||||
title: "Character Visuals Specification"
|
||||
description: "Complete spec for character rendering: layer stack, color regions, body/face/hair types, direction system, outline, LOD"
|
||||
type: spec
|
||||
status: active
|
||||
sprint: 28
|
||||
ticket: 684
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Character Visuals Specification
|
||||
|
||||
**Produced by:** Sprint 28 Character Visuals Workshop (ticket #684)
|
||||
**Date:** 2026-03-17
|
||||
**Art direction reference:** Invisible Inc — clean, adult, high contrast. Not a candy store.
|
||||
**Downstream:** `docs/design/compositor-api-spec.md`, tickets #686–#692 (visual team), #693 (compositor)
|
||||
|
||||
---
|
||||
|
||||
## 1. Camera and Display Context
|
||||
|
||||
All art must be authored and tested at the confirmed gameplay camera angle:
|
||||
|
||||
- **Camera tilt:** 30° from vertical (60° from horizontal) — orthographic
|
||||
- **Map rotation:** 45° (diamond grid)
|
||||
- **Projection:** Orthographic — no perspective foreshortening
|
||||
- **Reference games:** Hades, Divinity: Original Sin (not Diablo — 45° is NOT the default)
|
||||
|
||||
Characters are rendered as live 3D models in the Godot scene (D-149). The camera is a real `Camera3D` — perspective is not faked in art. Artwork is authored as 3D assets, not pre-rendered sprites.
|
||||
|
||||
**Art direction notes at this angle:**
|
||||
- Front faces are significantly visible at 30° — face and clothing detail matters more than at 45°
|
||||
- The top of characters' heads is less prominent — hair silhouette reads from the side, not top-down
|
||||
- Wall depth and character shadow are both visible — spatial grounding is strong
|
||||
- Characters must read clearly at tile scale (~32–64px display height)
|
||||
|
||||
---
|
||||
|
||||
## 2. Rendering Architecture Overview
|
||||
|
||||
Characters use a `CharacterCompositor` (Node3D subtree). See `docs/design/compositor-api-spec.md` for the full API.
|
||||
|
||||
- **Not sprites:** No pre-rendered sprite sheets. All composition is runtime in Godot.
|
||||
- **Outline:** Inverted hull method — uniform dark gray (#1a1a1a) for all characters. Not a relationship indicator (D-154). See §10.
|
||||
- **Relationship data:** Lives on minimap, name bubbles, and insert/perception mode overlay (D-033 amended). Not on character body in normal gameplay.
|
||||
- **Player character:** Visually identical to NPCs — same models, same variety (D-153).
|
||||
|
||||
---
|
||||
|
||||
## 3. Direction System
|
||||
|
||||
**Server (authoritative):** 8 facing directions — N, NE, E, SE, S, SW, W, NW.
|
||||
|
||||
**Client Sprint 28:** 4 visual groups. The 3D model rotates to the true 8-direction angle; only the visual mesh group snaps to 4:
|
||||
|
||||
| Visual Group | Covers Server Facings | Mirror |
|
||||
|---|---|---|
|
||||
| North | N, NW | — |
|
||||
| East | NE, E | — (source mesh) |
|
||||
| South | SE, S | — |
|
||||
| West | SW, W | Mirror of East |
|
||||
|
||||
The exact mapping of server facings to visual groups depends on camera orientation relative to the diamond grid — Tyre to confirm in compositor implementation. West is a horizontal mirror of East; no additional art required.
|
||||
|
||||
**Character editor:** Four cardinal direction buttons. Button order: **S → E → N → W**. Default on open: **South** (face-forward at 30° camera — maximum cosmetic utility, highest clothing/face visibility). No free-spin (D-155). The four buttons correspond directly to the four visual groups.
|
||||
|
||||
**Critical distinction — 8 facings vs 4 visual groups:**
|
||||
- **8 server-side facings** = perception system input. Used by fog-of-war, vision cone direction, and all simulation logic. The server always tracks the true 8-direction facing.
|
||||
- **4 visual groups** = character rendering output. What the player actually sees. Characters snap to the nearest cardinal visual group. Diagonal facings do not exist as visible character states — a character facing NE renders as the East visual group.
|
||||
- Smooth rotation interpolation between directions is applied at the model root level (ModelRoot rotation), so the body physically points the correct way even while the visual mesh group is a snap.
|
||||
|
||||
**Post-Sprint 28:** Diagonal mesh variants (NE, NW, SE, SW as separate visual groups) can be added without protocol changes — the 8 server facings are already tracked.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer Stack
|
||||
|
||||
Layers are rendered bottom-to-top. Each layer is a separate mesh in the `CharacterCompositor` subtree.
|
||||
|
||||
| # | Layer | Notes |
|
||||
|---|---|---|
|
||||
| 1 | Body base | Body type variant (slim / average / stocky). Skin tone mesh regions. |
|
||||
| 2 | Leg clothing | Trousers, shorts, skirt. Occludes body base from waist down. |
|
||||
| 3 | Footwear | Shoes, boots. Occludes leg clothing at ankle/foot. |
|
||||
| 4 | Torso clothing (back) | Rear of jacket/shirt. Behind arms. |
|
||||
| 5 | Torso clothing (front) | Front of jacket/shirt. Over arms. |
|
||||
| 6 | Accessories | Belt, bag, holster, jewelry. Multiple items possible. |
|
||||
| 7 | Head / face | Face mesh with skin regions. Neck connects to body base. |
|
||||
| 8 | Hair (back) | Hair behind the head/shoulders. |
|
||||
| 9 | Hair (front) | Hair in front of face / over forehead. |
|
||||
| — | Outline | Inverted hull pass (not a layer — shader effect on all meshes). |
|
||||
| 10 | Scar overlays | Placed on exposed skin regions. Additive blend on skin. |
|
||||
| 11 | Tattoo overlays | Placed on exposed skin regions. Multiply blend on skin. |
|
||||
| 12 | Injury overlays | Applied over clothing (tears, stains) and face (bruises, cuts). |
|
||||
| 13 | Expression overlay | Replaces/modifies face mesh for expression state. |
|
||||
|
||||
**Occlusion rule:** Each layer should include an occlusion mesh (invisible, write-only depth) matching the body shape, so underlying layers do not bleed through clothing at edges.
|
||||
|
||||
---
|
||||
|
||||
## 5. Color Mesh Regions
|
||||
|
||||
Each layer exposes named UV-mapped color regions that can be overridden by the compositor. Regions that are not overridden use the layer's authored default color.
|
||||
|
||||
### Body base
|
||||
| Region | Description |
|
||||
|---|---|
|
||||
| `skin_primary` | Face, hands, neck, visible forearms |
|
||||
| `skin_secondary` | Inner arm, sole of hand — subtle variation (may auto-derive from `skin_primary`) |
|
||||
|
||||
### Hair
|
||||
| Region | Description |
|
||||
|---|---|
|
||||
| `hair_primary` | Main hair color |
|
||||
| `hair_highlight` | Secondary sheen / highlight (may auto-derive from `hair_primary` as lighter tint) |
|
||||
|
||||
### Clothing (all clothing items)
|
||||
| Region | Description |
|
||||
|---|---|
|
||||
| `cloth_primary` | Dominant fabric — the main color the player picks |
|
||||
| `cloth_secondary` | Trim, seams, buttons, collar, cuffs |
|
||||
| `cloth_accent` | Optional third zone — logo patch, lining, contrast panel. May be absent on simpler items. |
|
||||
|
||||
### Accessories
|
||||
| Region | Description |
|
||||
|---|---|
|
||||
| `accessory_primary` | Main material color |
|
||||
| `accessory_secondary` | Optional secondary (e.g. stitching on a bag, gem on jewelry) |
|
||||
|
||||
**Design note:** Not all clothing recolors uniformly. A jacket's body is `cloth_primary`; its piping is `cloth_secondary`. Per-item region definition is the artist's responsibility. Invisible Inc reference: colored bodysuits with trim lines — crisp and readable at small scale.
|
||||
|
||||
---
|
||||
|
||||
## 6. Body Types (Sprint 28: 2–3)
|
||||
|
||||
Sprint 28 ships with 2–3 body type variants. Minimum viable: slim + average. Stocky as stretch if time allows.
|
||||
|
||||
| Type | Visual description | Notes |
|
||||
|---|---|---|
|
||||
| Slim | Narrow shoulders, slight frame, elongated limbs | |
|
||||
| Average | Medium proportions, balanced silhouette | |
|
||||
| Stocky | Broad shoulders, thicker torso and limbs | Stretch goal, Sprint 28 |
|
||||
|
||||
**Constraints:**
|
||||
- All body types share the same layer interface (`skin_primary`, `skin_secondary` region names)
|
||||
- Clothing mesh layers must fit all body types without visible clipping or floating
|
||||
- Clothing artists author per–body-type mesh variants OR the compositor scales the clothing mesh to the body type (implementation TBD — Tyre to specify in compositor-api-spec)
|
||||
- Silhouette must differ meaningfully at 32px display height — not just subtle (otherwise pointless at tile scale)
|
||||
|
||||
**Combination validation:** Sprint 28 goal is to confirm that switching body types with any clothing combination produces no visual artifacts. This is the primary QA target.
|
||||
|
||||
---
|
||||
|
||||
## 7. Face Types
|
||||
|
||||
Face is a compositable region on the `Head/Face` layer.
|
||||
|
||||
**Range:** Broad variety. Asymmetric features valued — these are adults, not game-industry default faces. Invisible Inc reference for readability at small scale.
|
||||
|
||||
**Mesh regions on face:**
|
||||
- `skin_primary` — face, forehead, cheeks, chin
|
||||
- `skin_lips` — lips (may auto-derive from `skin_primary`)
|
||||
|
||||
**Compositable elements within the face region (each a separate overlay or mesh swap):**
|
||||
- Eye shape (mesh swap — left/right eye as separate swappable meshes or UV regions)
|
||||
- Nose shape (mesh swap)
|
||||
- Mouth/lip shape (mesh swap)
|
||||
- Eyebrow shape (mesh swap or UV overlay)
|
||||
|
||||
**Expression states (minimum Sprint 28):**
|
||||
|
||||
| State | Description | Trigger |
|
||||
|---|---|---|
|
||||
| `neutral` | Default resting face | No active emotional state |
|
||||
| `alert` | Eyes slightly widened, posture tense | Danger or person-of-interest nearby |
|
||||
| `stressed` | Furrowed brow, tight mouth | High stress value from simulation |
|
||||
| `distressed` | Open distress, visible tension | Extreme stress or injury |
|
||||
|
||||
Expression overlay replaces or modifies the base face mesh. The expression does not affect the body posture in Sprint 28.
|
||||
|
||||
---
|
||||
|
||||
## 8. Hair Style Spec
|
||||
|
||||
Hair silhouette must read legibly at tile scale. Overly detailed styles will read as noise at small size — err on the side of clean, strong silhouettes.
|
||||
|
||||
**Minimum Sprint 28 styles:**
|
||||
- Short A (close-cropped)
|
||||
- Short B (textured/spiky)
|
||||
- Medium A (smooth, parted)
|
||||
- Medium B (wavy/voluminous)
|
||||
- Long A (flowing, shoulder-length or longer)
|
||||
- Bald / shaved
|
||||
|
||||
**Color regions:** `hair_primary`, `hair_highlight` (see §5).
|
||||
|
||||
**Layer split:** Every style must have a `Hair (back)` and `Hair (front)` mesh — these straddle the head/face layer so face is visible between them at certain angles.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scar and Tattoo Overlays
|
||||
|
||||
Overlays are placed on **exposed skin regions only** (face, hands, forearms — not on covered areas).
|
||||
|
||||
**Placement zones:**
|
||||
- Face zone
|
||||
- Left arm / hand zone
|
||||
- Right arm / hand zone
|
||||
- Torso zone (only visible when wearing open/sleeveless clothing)
|
||||
|
||||
**Blending:**
|
||||
- **Tattoos:** Multiply blend on `skin_primary` region — color mixes with skin tone
|
||||
- **Scars:** Additive blend on `skin_primary` — lighter, raised-texture appearance
|
||||
|
||||
**Optional:** Not all characters have scars or tattoos. Zero overlays is valid and common.
|
||||
|
||||
---
|
||||
|
||||
## 10. Injury Overlays
|
||||
|
||||
Injury state represents physical damage visible on the character's appearance.
|
||||
|
||||
**States (minimum Sprint 28):**
|
||||
|
||||
| State | Clothing effect | Skin effect |
|
||||
|---|---|---|
|
||||
| `undamaged` | None | None |
|
||||
| `light_injury` | Minor tear/dirt on torso clothing | Minor bruising on face/exposed skin |
|
||||
| `heavy_injury` | Visible tears, blood staining | Heavy bruising, cuts visible |
|
||||
|
||||
Injury overlays are applied on top of clothing (not inside it) and on top of exposed skin. Injury tint auto-derives from the layer's base color (darker, desaturated).
|
||||
|
||||
---
|
||||
|
||||
## 11. Outline Specification
|
||||
|
||||
- **Method:** Inverted hull — GPU vertex extrusion, back-face-only render pass (D-150)
|
||||
- **Color:** `#1e1e24` (very dark blue-grey) for all characters, always — not pure black
|
||||
- **Width:** 1px at gameplay zoom (consistent regardless of zoom level — use shader-based fixed screen-space width)
|
||||
- **Semantic content:** None — the outline does not encode relationship, status, or any game state
|
||||
- **LOD behavior:** At Tier 2 (billboard impostor), the outline is baked into the impostor sprite. The inverted hull is disabled at Tier 2 — no separate draw call.
|
||||
|
||||
---
|
||||
|
||||
## 12. LOD Strategy
|
||||
|
||||
Three tiers, degraded based on GPU frame budget (not distance, not fixed count) (D-152):
|
||||
|
||||
| Tier | Label | Description |
|
||||
|---|---|---|
|
||||
| 0 | Full | All compositor layers active. Full 3D model. Inverted hull outline. |
|
||||
| 1 | Simplified mesh | Reduced-poly model. Clothing layers merged into combined mesh. Outline active. |
|
||||
| 2 | Billboard impostor | Flat sprite impostor with outline baked in. One draw call. |
|
||||
|
||||
**Degradation order:** Characters furthest from the player's character degrade first. Nearest characters stay at Tier 0 longest. Threshold is a frame budget check — when the GPU is under pressure, demote the outermost tier-eligible characters.
|
||||
|
||||
**LOD trigger:** Proactive on **projected character count**, not reactive on frame drop. The LOD manager anticipates load based on how many characters will be in the scene this frame and pre-demotes before GPU pressure occurs. Reactive triggering (demoting after a frame drops) produces visible hitches. Proactive triggering is invisible.
|
||||
|
||||
**Billboard LOD requirement (Tier 2 preservation):** At billboard tier, each impostor sprite **must visually encode two signals per character:**
|
||||
1. **Body size tier** (slim / average / stocky) — silhouette must differ meaningfully at billboard scale
|
||||
2. **Dominant clothing color** — the primary `cloth_primary` of the most visible clothing item must be readable
|
||||
|
||||
These signals must not be optimized away during impostor baking. The impostor is not a generic character silhouette — it is a per-character snapshot preserving identity.
|
||||
|
||||
**Pause behavior:** When the game is paused, the render budget is fully freed. All characters restore to Tier 0. The pause button is a deliberate affordance — player can inspect any character at full detail.
|
||||
|
||||
**Chaos feel:** In a very large crowd (400+ characters), peripheral degradation is intentional and cognitively appropriate — real crowds have visual noise at the edges. The player naturally focuses center-screen where Tier 0 characters are.
|
||||
|
||||
---
|
||||
|
||||
## 13. Art Direction Reference Summary
|
||||
|
||||
| Principle | Notes |
|
||||
|---|---|
|
||||
| Reference: Invisible Inc | Clean, sleek, adult. High contrast, strong silhouettes. Not Rimworld-chunky, not anime. |
|
||||
| Scale readability | Every design decision must be validated at 32–64px. Detail that doesn't read at tile scale should be removed. |
|
||||
| Uniform outlines | Dark gray for everyone. No auras, no glows, no player distinction. |
|
||||
| Per-item color variety | Clothing colors are per-item, not per-faction, not per-team. Life-sim, not RTS. |
|
||||
| Adult faces | Asymmetric features, variety across ages and types. Not default RPG faces. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Open Questions / Flags
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Exact N/E/S/W → server facing mapping | Open | Tyre to confirm in compositor-api-spec based on camera orientation |
|
||||
| Stocky body type (Sprint 28) | Conditional | Stretch goal — drop if time pressure |
|
||||
| Clothing mesh per body type vs. scale | Open | Tyre to specify — separate mesh variants or compositor-level scaling |
|
||||
| Torso-zone tattoo visibility | Open | Depends on clothing occlusion rules — artist + Tyre to confirm |
|
||||
| Expression overlay = mesh swap or morph target | Open | Tyre to decide in compositor-api-spec |
|
||||
| D-146 interaction with 3D rendering | Flag | D-146 says "tile-scale sprite with heavy zoom" for editor preview; D-149 says live 3D rendering. The editor preview may show the live 3D model at zoom scale — compatible if interpreted as "tile-scale representation" not "2D sprite". Do not reopen D-146 without explicit direction from Jeroen. |
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
title: "Character Compositor API Specification"
|
||||
description: "Godot-side compositor data model and node architecture for runtime character compositing"
|
||||
type: spec
|
||||
status: active
|
||||
sprint: 28
|
||||
ticket: 684
|
||||
created: 2026-03-17
|
||||
---
|
||||
|
||||
# Character Compositor API Specification
|
||||
|
||||
**Produced by:** Sprint 28 Character Visuals Workshop (ticket #684)
|
||||
**Date:** 2026-03-17
|
||||
**Implements:** D-149 (3D live rendering), D-150 (inverted hull outline), D-151 (direction count), D-152 (LOD strategy)
|
||||
**Upstream:** `docs/design/character-visuals-spec.md`
|
||||
**Downstream:** Ticket #693 (compositor implementation, client team)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The `CharacterCompositor` is a `Node3D` subtree that replaces the current single `Sprite2D` in `EntityRenderer`. It manages all visual layers for a character: body, clothing, hair, face, accessories, and overlays. It exposes a clean API that `EntityRenderer` calls when appearance or state changes.
|
||||
|
||||
The compositor is **purely presentational** — it never queries the server or simulation. All data flows from `EntityRenderer` downward.
|
||||
|
||||
---
|
||||
|
||||
## 2. Direction Enum
|
||||
|
||||
```gdscript
|
||||
enum CharacterFacing {
|
||||
NORTH, # Server facings: N, NW
|
||||
EAST, # Server facings: NE, E (source mesh — not mirrored)
|
||||
SOUTH, # Server facings: SE, S
|
||||
WEST, # Server facings: SW, W (mirror of EAST)
|
||||
}
|
||||
```
|
||||
|
||||
**Perception vs. rendering — critical distinction:**
|
||||
- `CharacterFacing` (4 values) is the **rendering output** — what the compositor acts on.
|
||||
- The server tracks 8 facing directions for the **perception system** (fog-of-war, vision cone). The server's 8-direction value is never passed directly to the compositor.
|
||||
- `EntityRenderer` is responsible for mapping the server's 8-direction value to one of the 4 `CharacterFacing` values and passing that to `compositor.set_facing()`.
|
||||
- Diagonal facings (NE, NW, SE, SW) do not exist as compositor states. They are perception inputs only.
|
||||
- `ModelRoot` rotation uses the true 8-direction angle for body lean; `CharacterFacing` controls mesh/material group selection.
|
||||
|
||||
**Server-to-client mapping** (exact snap logic — Tyre to confirm based on camera/grid orientation):
|
||||
|
||||
| Server Facing | Client Visual Group | Notes |
|
||||
|---|---|---|
|
||||
| N | NORTH | |
|
||||
| NW | NORTH | |
|
||||
| NE | EAST | |
|
||||
| E | EAST | |
|
||||
| SE | SOUTH | |
|
||||
| S | SOUTH | |
|
||||
| SW | WEST | Mirror of EAST |
|
||||
| W | WEST | Mirror of EAST |
|
||||
|
||||
The 3D model root rotates to the true server-facing angle (all 8). The visual group snap controls which mesh variant is loaded, but the root rotation gives subtle body lean within a group.
|
||||
|
||||
---
|
||||
|
||||
## 3. Color Override Data
|
||||
|
||||
```gdscript
|
||||
class_name CharacterColors
|
||||
extends Resource
|
||||
|
||||
## Skin
|
||||
@export var skin_primary: Color = Color("#c8a882")
|
||||
@export var skin_secondary: Color = Color.TRANSPARENT # TRANSPARENT = auto-derive from skin_primary
|
||||
|
||||
## Hair
|
||||
@export var hair_primary: Color = Color("#3a2a1a")
|
||||
@export var hair_highlight: Color = Color.TRANSPARENT # TRANSPARENT = auto-derive from hair_primary
|
||||
|
||||
## Clothing slot overrides — keyed by item_id
|
||||
## Each item carries its own cloth_primary/secondary/accent
|
||||
## This dict maps item_id -> ClothingColors
|
||||
@export var clothing: Dictionary = {} # item_id (int) -> ClothingColors
|
||||
```
|
||||
|
||||
```gdscript
|
||||
class_name ClothingColors
|
||||
extends Resource
|
||||
|
||||
@export var cloth_primary: Color = Color.WHITE
|
||||
@export var cloth_secondary: Color = Color.TRANSPARENT # TRANSPARENT = use authored default
|
||||
@export var cloth_accent: Color = Color.TRANSPARENT # TRANSPARENT = use authored default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Appearance Data
|
||||
|
||||
```gdscript
|
||||
class_name CharacterAppearance
|
||||
extends Resource
|
||||
|
||||
## Body
|
||||
@export var body_type: int = 1 # 0=slim, 1=average, 2=stocky
|
||||
@export var face_id: int = 0 # index into face mesh library
|
||||
@export var hair_id: int = 0 # index into hair mesh library
|
||||
|
||||
## Clothing — item IDs (0 = no item for that slot)
|
||||
@export var clothing_torso_id: int = 0
|
||||
@export var clothing_legs_id: int = 0
|
||||
@export var footwear_id: int = 0
|
||||
@export var accessory_ids: Array[int] = [] # multiple accessories supported
|
||||
|
||||
## Overlays — IDs (empty = none)
|
||||
@export var scar_ids: Array[int] = []
|
||||
@export var tattoo_ids: Array[int] = []
|
||||
|
||||
## State
|
||||
@export var injury_state: int = 0 # 0=undamaged, 1=light, 2=heavy
|
||||
@export var expression_state: int = 0 # 0=neutral, 1=alert, 2=stressed, 3=distressed
|
||||
|
||||
## Colors
|
||||
@export var colors: CharacterColors = CharacterColors.new()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Node Architecture
|
||||
|
||||
```
|
||||
CharacterCompositor (Node3D) — root; receives API calls
|
||||
├── ModelRoot (Node3D) — rotated to true server-facing angle
|
||||
│ ├── BodyMesh (MeshInstance3D) — body_type variant; skin regions
|
||||
│ ├── LegClothing (MeshInstance3D) — clothing_legs_id mesh; cloth regions
|
||||
│ ├── Footwear (MeshInstance3D) — footwear_id mesh; cloth regions
|
||||
│ ├── TorsoClothingBack (MeshInstance3D) — back half of torso clothing
|
||||
│ ├── TorsoClothingFront (MeshInstance3D) — front half of torso clothing
|
||||
│ ├── Accessories (Node3D) — child MeshInstance3D per accessory_id
|
||||
│ ├── HeadFace (MeshInstance3D) — face_id variant; skin + expression regions
|
||||
│ ├── HairBack (MeshInstance3D) — hair_id variant (back mesh)
|
||||
│ ├── HairFront (MeshInstance3D) — hair_id variant (front mesh)
|
||||
│ └── Overlays (Node3D)
|
||||
│ ├── ScarOverlay (MeshInstance3D) — scar_ids; additive blend
|
||||
│ ├── TattooOverlay (MeshInstance3D) — tattoo_ids; multiply blend
|
||||
│ ├── InjuryOverlay (MeshInstance3D) — injury_state; over clothing + skin
|
||||
│ └── ExpressionOverlay (MeshInstance3D) — expression_state; over face
|
||||
└── LOD (Node3D) — manages tier transitions
|
||||
└── BillboardSprite (Sprite3D) — tier 2 impostor (baked outline included)
|
||||
```
|
||||
|
||||
**Outline:** Inverted hull is a material property on `ModelRoot` and all child meshes — not a separate node. Disabled at LOD Tier 2 (billboard replaces the entire ModelRoot).
|
||||
|
||||
---
|
||||
|
||||
## 6. Public API
|
||||
|
||||
```gdscript
|
||||
class_name CharacterCompositor
|
||||
extends Node3D
|
||||
|
||||
## Apply a full appearance update.
|
||||
## Called on: character creation, equip/unequip, editor preview.
|
||||
func apply_appearance(appearance: CharacterAppearance) -> void:
|
||||
pass
|
||||
|
||||
## Update facing direction. Called every time server reports a facing change.
|
||||
func set_facing(facing: CharacterFacing) -> void:
|
||||
pass
|
||||
|
||||
## Set LOD tier. Called by the global LOD manager based on frame budget.
|
||||
## 0 = full, 1 = simplified mesh, 2 = billboard impostor
|
||||
func set_lod_tier(tier: int) -> void:
|
||||
pass
|
||||
|
||||
## Return a snapshot of the current appearance (used by character editor).
|
||||
func get_appearance() -> CharacterAppearance:
|
||||
return CharacterAppearance.new()
|
||||
|
||||
## Update a single color region without a full appearance rebuild.
|
||||
## More efficient than apply_appearance() for color picker preview.
|
||||
func set_color(region: StringName, color: Color) -> void:
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. EntityRenderer Integration
|
||||
|
||||
Current state: `EntityRenderer` (`client/scripts/rendering/entity_renderer.gd`) uses a single `Sprite2D` per entity.
|
||||
|
||||
**Migration plan:**
|
||||
1. Add `CharacterCompositor` as a packed scene resource
|
||||
2. In `EntityRenderer._ready()`: if entity is a character type, instantiate `CharacterCompositor` and add as child; hide `Sprite2D`
|
||||
3. Wire `EntityRenderer`'s existing facing-update path to call `compositor.set_facing()`
|
||||
4. Wire `EntityRenderer`'s appearance-update path to call `compositor.apply_appearance()`
|
||||
5. Register `EntityRenderer` with the global LOD manager to receive `set_lod_tier()` calls
|
||||
|
||||
Non-character entities (items, furniture, tiles) continue to use `Sprite2D` and are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 8. LOD Manager
|
||||
|
||||
A singleton (`CharacterLODManager`) monitors the GPU frame budget and calls `set_lod_tier()` on registered `CharacterCompositor` instances.
|
||||
|
||||
**Interface:**
|
||||
```gdscript
|
||||
class_name CharacterLODManager
|
||||
extends Node
|
||||
|
||||
## Register a compositor for LOD management.
|
||||
func register(compositor: CharacterCompositor, entity_id: int) -> void:
|
||||
pass
|
||||
|
||||
## Unregister when entity leaves scene.
|
||||
func unregister(entity_id: int) -> void:
|
||||
pass
|
||||
|
||||
## Called by compositor to report its distance from player (updated each frame by EntityRenderer).
|
||||
func update_distance(entity_id: int, distance: float) -> void:
|
||||
pass
|
||||
```
|
||||
|
||||
**LOD algorithm — proactive, not reactive:**
|
||||
The LOD manager operates on **projected character count**, not frame-drop detection. Triggering on frame drop produces visible hitches. Proactive demotion is invisible to the player.
|
||||
|
||||
```
|
||||
Each frame:
|
||||
projected_full_count = count of registered compositors within full-detail radius
|
||||
if projected_full_count > TIER_0_BUDGET:
|
||||
demote furthest tier-0 compositors until within budget
|
||||
if still over TIER_1_BUDGET:
|
||||
demote furthest tier-1 compositors to tier-2 until within budget
|
||||
When count drops below budget * HYSTERESIS_FACTOR:
|
||||
promote nearest tier-2 → tier-1, tier-1 → tier-0
|
||||
When paused (get_tree().paused == true):
|
||||
promote all compositors to tier 0
|
||||
```
|
||||
|
||||
`TIER_0_BUDGET` and `TIER_1_BUDGET` are tunable constants. Initial values TBD by profiling.
|
||||
|
||||
**Billboard impostor requirements (Tier 2):**
|
||||
The impostor sprite is not a generic silhouette — it is a per-character snapshot. The bake process **must** preserve:
|
||||
1. **Body size tier** — slim / average / stocky silhouette must be distinguishable at billboard scale
|
||||
2. **Dominant clothing color** — `cloth_primary` of the most visible clothing item must read clearly
|
||||
|
||||
These are non-negotiable fidelity requirements. Implementation must not optimize them away.
|
||||
|
||||
Implementation detail: use an indexed priority queue keyed by distance. Full implementation is Tyre's responsibility in ticket #693.
|
||||
|
||||
---
|
||||
|
||||
## 9. Character Editor Integration
|
||||
|
||||
The character creation/editor screen (`character_select.gd` and related) can use `CharacterCompositor` directly for the preview pane.
|
||||
|
||||
**Preview pane setup:**
|
||||
- Instantiate `CharacterCompositor` in a `SubViewport`
|
||||
- Apply `CharacterAppearance` from the current editor state on every change
|
||||
- Default facing on open: **`CharacterFacing.SOUTH`** — face-forward at 30° camera, maximum cosmetic utility
|
||||
- Button order: S → E → N → W
|
||||
- No LOD management in the editor — always tier 0
|
||||
|
||||
```gdscript
|
||||
# In editor preview pane script:
|
||||
func _on_direction_button_pressed(facing: CharacterFacing) -> void:
|
||||
preview_compositor.set_facing(facing)
|
||||
|
||||
func _on_appearance_changed() -> void:
|
||||
preview_compositor.apply_appearance(build_appearance_from_editor_state())
|
||||
```
|
||||
|
||||
**D-146 compatibility note:** D-146 specifies "tile-scale sprite with heavy zoom." Under D-149 (3D live rendering), the editor preview shows the live 3D compositor rendered in a SubViewport at tile scale, then displayed zoomed. This is compatible with D-146's intent (showing the actual in-game appearance, not a separate portrait render). D-146 is not superseded.
|
||||
|
||||
---
|
||||
|
||||
## 10. Asset Pipeline Notes
|
||||
|
||||
**For Araminta (visual team):**
|
||||
- Each body type variant is a separate `.blend`/`.glb` export for `BodyMesh`
|
||||
- Clothing items export as separate `.glb` per body type (e.g. `jacket_01_slim.glb`, `jacket_01_average.glb`)
|
||||
- Hair styles export as two meshes per style: `hair_short_a_back.glb`, `hair_short_a_front.glb`
|
||||
- Color regions are defined via UV2 channel: compositor reads UV2 to apply `ShaderMaterial` color overrides per region
|
||||
- Naming convention for regions: UV2 islands correspond to named shader uniforms (`uniform sampler2D skin_primary_mask`, etc.)
|
||||
|
||||
**For tickets #686–#692 (visual team):**
|
||||
- Reference this spec and the `CharacterAppearance` data class for mesh naming and region conventions
|
||||
- All meshes must validate at the 30° tilt, 45° map rotation camera (D-148) — no art review at top-down angle
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
| Item | Status | Owner |
|
||||
|---|---|---|
|
||||
| Exact server-facing → visual group mapping | Open | Tyre — confirm against camera/grid orientation in compositor implementation |
|
||||
| Expression: mesh swap vs. morph target | Open | Tyre — cost/quality tradeoff |
|
||||
| Clothing mesh per body type vs. compositor-level scale | Open | Tyre — separate `.glb` per body type is simpler; scaling risks clipping |
|
||||
| UV2 region masking vs. vertex color for color regions | Open | Tyre — UV2 is cleaner; vertex color is cheaper; both work |
|
||||
| Impostor bake process for LOD Tier 2 | Open | Tyre — static pre-bake vs. runtime bake |
|
||||
Reference in New Issue
Block a user