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>
299 lines
12 KiB
Markdown
299 lines
12 KiB
Markdown
---
|
||
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 |
|