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

12 KiB
Raw Permalink Blame History

title, description, type, status, sprint, ticket, created
title description type status sprint ticket created
Character Compositor API Specification Godot-side compositor data model and node architecture for runtime character compositing spec active 28 684 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

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

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
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

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

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:

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 colorcloth_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
# 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