Files
settled-reach/docs/design/compositor-api-spec.md
jpmschweitzerandClaude Opus 4.6 6dee6b73f0 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>
2026-03-17 23:34:16 +01:00

299 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 |