본문으로 건너뛰기
CHOI HONGSU
1 min read

Character bounded shadow

 

High-quality character shadows limited to 16 lobby characters — Atlas slot + mask approach delivers readable silhouettes at mobile 1024 resolution.

  • 256²  캐릭터당 텍셀 (1024 atlas)
  • 16 동시 캐스터 한도 (Player 우선 + NPC 카메라 거리순)

Problem

URP's default cascaded shadow map covers the whole screen with a single map, so each character gets very few texels. We needed a separate shadow region that guarantees a per-character texel budget.

Approach

Cascade Shadow Map 해상도 상향

Chosen

Pros

  • Same pipeline as before, low implementation cost

Cons

  • Increases memory / bandwidth cost, no per-character texel guarantee, affects the whole scene

Atlas 슬롯 + 깊이 비교

Pros

  • Guarantees 256² texels per slot, per-character capture, reuses URP's shadow comparison logic

Cons

  • 16-bit depth + small slots carry a heavy acne / bias tuning burden

Atlas 슬롯 + 마스크 방식 (chosen)

Pros

  • No bias parameter, 16-bit depth is sufficient, simpler shader

Cons

  • No depth info → shadow errors possible in multi-layer maps

Why Cascade Shadow Map 해상도 상향: In the lobby, characters sit on a single plane, so the benefit of depth comparison is small while acne / bias tuning cost on a 16-bit depth + 256² slot is high. Depth comparison itself was removed; only ceiling / wall influence is blocked by a normalWS.y-based SlopeFade.

Architecture

The RendererFeature clears the 1024 atlas RT at BeforeRenderingOpaques and renders each caster mesh depth-only into 16 slots. Per caster, the 8 corners of the union bounds are transformed into view space to form a tight orthographic AABB that fits the slot texels snugly. The receiver shader transforms worldPos to slot UV via _CharacterShadowVP[i], and any pixel that is not the clear value is treated as shadow.

diagram: /images/projects/ovensmash/character-shadow-atlas/architecture.svg

diagram caption: Caster → Manager → AtlasPass → Globals → Receiver Shader

Implementation

  1. 01

    Step 1. Caster component + Union Bounds hysteresis

    A MonoBehaviour attached to the character root. It auto-collects only the child SkinnedMeshRenderers and computes the union bounds. To prevent the bounds from "pumping" frame-to-frame due to bone animation, hysteresis is applied to extents (expand immediately / shrink via lerp); center tracks immediately so movement responsiveness is preserved.

    • code (csharp): the hysteresis section in CharacterShadowCaster.GetUnionBounds (Vector3.Max then Vector3.Lerp).
  2. 02

    Step 2. Manager — 16-slot priority

    A scene singleton registers casters split into Player / NPC, and each frame selects up to 16 — all Players first, then NPCs ordered by camera distance. There's also a recovery path that recollects from the scene when static references are broken after a domain reload.

  3. 03

    Step 3. Tight AABB Frustum (the focus of this WIP)

    The previous version estimated the shadow's horizontal drift from the light's tilt and grew the frustum to a square radius. Now the 8 corners of the bounds are transformed into view space and a tight orthographic AABB (xMin/xMax/yMin/yMax) is computed directly, maximizing the texels the character silhouette occupies inside the same 256² slot.

    • code (csharp): the 8-corner loop in ComputeMatrices and Matrix4x4.Ortho(xMin, xMax, yMin, yMax, near, far).
  4. 04

    Step 4. Mask-style shadow test

    Dropped the conventional depth comparison (coord.z vs stored + bias) and simplified to "a pixel where a caster was drawn into the atlas = shadow." Under reversed Z, clear is 0, so stored > 0 is a hit. The _CharShadowBias parameter is gone, and a normalWS.y-based SlopeFade prevents shadow bleeding onto ceilings / walls.

    • code (hlsl): the stored sample + UNITY_REVERSED_Z-branched hit test in EvalCharacterShadow.

Before / After

1024 resolution · same character

 

After
Before
BEFOREAFTER

디버깅 모드

Conclusion

Shadow quality improved at the same texture resolution