Outline render order, stencil protection, JFA sentinel fix, diagnostics

Render order: 3-way M2 batch partition — game objects + local player
render first (write depth), then outline targets (stencil marks), then
other players/gear/NPCs. Outlines show through other players but are
occluded by world/WMO/game objects/local player.

Stencil protection: set STENCILWRITEMASK=0 after outline target DIPs
to prevent subsequent renders from overwriting stencil marks.

JFA sentinel: changed from (1,1) to (-1,-1) to move it outside UV
space. Did not fix banding but is correct regardless.

Debug: added DEBUG_SHOW_SILHOUETTE comptime flag to bypass JFA and
composite raw silhouette RT. Confirmed silhouette is clean — banding
is in the JFA pipeline, not stale vertex buffers.

Game object + local player tracking in tracker.zig for batch ordering.

Added project README and outline subsystem README documenting render
pipeline, architecture, known issues, and planned features.
This commit is contained in:
MarcelineVQ
2026-02-25 18:32:49 -08:00
parent 661e1562d9
commit fbfe1cb545
6 changed files with 656 additions and 33 deletions
+98 -16
View File
@@ -8,11 +8,10 @@
//! marks where models pass the terrain depth test (stencil=1 = visible).
//! - **Reset**: forces D24S8 depth/stencil format, releases resources.
//!
//! Batch reordering in model_hook.zig ensures outline targets render LAST in
//! CM2SceneRenderDraw, after all other M2 models (game objects, characters,
//! NPCs) have filled the depth buffer. The DIP hook writes stencil marks
//! using the game's own depth buffer; EndScene replay uses these marks to
//! gate silhouette drawing (full scene occlusion including game objects).
//! Batch reordering in model_hook.zig partitions M2 batches into 3 groups:
//! game objects first (write depth), then outline targets (DIP hook writes
//! stencil against that depth), then other players/gear/NPCs. Outlines are
//! occluded by world/WMO/game objects but show through other players.
const std = @import("std");
const hook = @import("hook");
@@ -70,8 +69,14 @@ var outline_ps: ?*anyopaque = null; // flat-color PS (silhouettes)
var jfa_init_ps: ?*anyopaque = null; // JFA seed init PS
var jfa_prop_ps: ?*anyopaque = null; // JFA propagation PS
var jfa_decode_ps: ?*anyopaque = null; // JFA decode + composite PS
var debug_sil_ps: ?*anyopaque = null; // debug: composite silhouette directly
var shaders_attempted: bool = false;
// Debug: set to true to skip JFA and composite raw silhouette RT to backbuffer.
// Used to diagnose whether banding artifacts originate in the silhouette (Phase 1
// replay / stale VB) or in the JFA pipeline (Phase 2 shader bug).
const DEBUG_SHOW_SILHOUETTE = false;
// D3DXAssembleShader function pointer (loaded dynamically)
const D3DXAssembleShaderFn = *const fn (
[*]const u8, // pSrcData
@@ -459,10 +464,11 @@ fn releaseResources() void {
/// Flat colour pixel shader — outputs PS constant c0.
const ps_flat_src = "ps_3_0\nmov oC0, c0\n";
/// JFA init: sample silhouette, output own UV as seed or sentinel (1,1).
/// JFA init: sample silhouette, output own UV as seed or sentinel (-1,-1).
/// Sentinel must be outside [0,1] UV space so it never wins distance comparisons.
const jfa_init_src =
"ps_3_0\n" ++
"def c0, 1.0, 1.0, -0.002, 0.0\n" ++
"def c0, -1.0, -1.0, -0.002, 0.0\n" ++
"dcl_2d s0\n" ++
"dcl_texcoord0 v0\n" ++
"texld r0, v0, s0\n" ++
@@ -587,6 +593,18 @@ const jfa_decode_src =
"mov r4.xyz, r2.xyz\n" ++ // outline colour from seed
"mov oC0, r4\n";
/// Debug: composite silhouette RT directly. Forces alpha to 1.0 where silhouette
/// has any content (alpha >= 0.002), 0.0 elsewhere. Bypasses JFA entirely.
const debug_sil_src =
"ps_3_0\n" ++
"def c0, 0.0, 0.0, -0.002, 1.0\n" ++
"dcl_2d s0\n" ++
"dcl_texcoord0 v0\n" ++
"texld r0, v0, s0\n" ++
"add r1.x, r0.a, c0.z\n" ++ // alpha - 0.002
"cmp r0.w, r1.x, c0.w, c0.x\n" ++ // >= 0 → 1.0 (opaque), < 0 → 0.0 (transparent)
"mov oC0, r0\n";
// =============================================================================
// Shader creation
// =============================================================================
@@ -620,6 +638,14 @@ fn ensureShaders(device: *anyopaque) void {
releaseShaders();
return;
};
// --- Debug silhouette composite PS (only when diagnostic enabled) ---
if (DEBUG_SHOW_SILHOUETTE) {
debug_sil_ps = assemblePS(device, assemble, debug_sil_src, debug_sil_src.len) orelse {
releaseShaders();
return;
};
}
}
/// Assemble a pixel shader from source text, create device PS object.
@@ -645,7 +671,7 @@ fn assemblePS(device: *anyopaque, assemble: D3DXAssembleShaderFn, src: [*]const
}
fn releaseShaders() void {
inline for (.{ &outline_ps, &jfa_init_ps, &jfa_prop_ps, &jfa_decode_ps }) |ps| {
inline for (.{ &outline_ps, &jfa_init_ps, &jfa_prop_ps, &jfa_decode_ps, &debug_sil_ps }) |ps| {
if (ps.*) |p| { comRelease(p); ps.* = null; }
}
shaders_attempted = false;
@@ -806,7 +832,7 @@ fn hkDIP(
const s_enable = deviceGetRS(device, types.D3DRS.STENCILENABLE);
const s_func = deviceGetRS(device, types.D3DRS.STENCILFUNC);
const s_ref = deviceGetRS(device, types.D3DRS.STENCILREF);
const s_wmask = deviceGetRS(device, types.D3DRS.STENCILWRITEMASK);
// (STENCILWRITEMASK not saved — intentionally set to 0 on restore)
const s_pass = deviceGetRS(device, types.D3DRS.STENCILPASS);
const s_fail = deviceGetRS(device, types.D3DRS.STENCILFAIL);
const s_zfail = deviceGetRS(device, types.D3DRS.STENCILZFAIL);
@@ -821,14 +847,19 @@ fn hkDIP(
const result = origFn(device, prim_type, base_vtx, min_vtx, num_verts, start_idx, prim_count);
// Restore stencil state to match WoW's GxDevice cache
// Restore stencil state to match WoW's GxDevice cache, but lock
// stencil writes to protect our marks from subsequent draws (other
// players' gear, NPCs, etc. that render after outline targets).
deviceSetRS(device, types.D3DRS.STENCILENABLE, s_enable);
deviceSetRS(device, types.D3DRS.STENCILFUNC, s_func);
deviceSetRS(device, types.D3DRS.STENCILREF, s_ref);
deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, s_wmask);
deviceSetRS(device, types.D3DRS.STENCILPASS, s_pass);
deviceSetRS(device, types.D3DRS.STENCILFAIL, s_fail);
deviceSetRS(device, types.D3DRS.STENCILZFAIL, s_zfail);
// Write mask 0 instead of restoring original — prevents any
// subsequent DIP from overwriting our stencil=1 marks.
// Restored properly in EndScene before the JFA pipeline.
deviceSetRS(device, types.D3DRS.STENCILWRITEMASK, 0);
return result;
}
@@ -988,6 +1019,38 @@ fn runJfaPipeline(device: *anyopaque) void {
_ = clearFn(device, 0, null, types.D3DCLEAR_STENCIL, 0, 1.0, 0);
}
// =====================================================================
// Debug: skip JFA, composite raw silhouette RT to see if banding is
// in the silhouette (stale VB / replay issue) or the JFA pipeline.
// =====================================================================
if (DEBUG_SHOW_SILHOUETTE) {
if (debug_sil_ps) |dps| {
if (saved_rt0) |rt| deviceSetRenderTarget(device, 0, rt);
deviceSetPtrOrNull(device, types.VT.SetDepthStencilSurface, null);
deviceSetPtrOrNull(device, types.VT.SetVertexShader, null);
deviceSetFVF(device, types.D3DFVF_XYZRHW | types.D3DFVF_TEX1);
deviceSetRS(device, types.D3DRS.ZENABLE, types.D3DZB_FALSE);
deviceSetRS(device, types.D3DRS.ZWRITEENABLE, 0);
deviceSetRS(device, types.D3DRS.CULLMODE, types.D3DCULL_NONE);
deviceSetRS(device, types.D3DRS.ALPHATESTENABLE, 0);
deviceSetRS(device, types.D3DRS.COLORWRITEENABLE, 0x0F);
deviceSetRS(device, types.D3DRS.ALPHABLENDENABLE, 1);
deviceSetRS(device, types.D3DRS.SRCBLEND, types.D3DBLEND_SRCALPHA);
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSU, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.ADDRESSV, types.D3DTADDRESS_CLAMP);
deviceSetSamplerState(device, 0, types.D3DSAMP.MAGFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MINFILTER, types.D3DTEXF_POINT);
deviceSetSamplerState(device, 0, types.D3DSAMP.MIPFILTER, types.D3DTEXF_NONE);
deviceSetTexture(device, 0, rt_silhouette_tex);
deviceSetPtr(device, types.VT.SetPixelShader, dps);
const quad = buildFullscreenQuad(vp.Width, vp.Height);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), @sizeOf(QuadVertex));
}
// Skip JFA — jump straight to state restore
} else {
// =====================================================================
// Phase 2: JFA pipeline (silhouette → outline composite)
// =====================================================================
@@ -1024,18 +1087,35 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetPtr(device, types.VT.SetPixelShader, jfa_init_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 2: JFA Propagation step=2 (JFA_A → JFA_B)
// JFA Propagation: steps [8, 4, 2, 1] ping-ponging between A and B.
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
var c0: [4]f32 = undefined;
// step=8 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
var c0 = [4]f32{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
c0 = .{ 8.0 / fw, 8.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceSetPtr(device, types.VT.SetPixelShader, jfa_prop_ps.?);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// Pass 3: JFA Propagation step=1 (JFA_B → JFA_A)
// step=4 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = [4]f32{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
c0 = .{ 4.0 / fw, 4.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=2 (JFA_A → JFA_B)
deviceSetRenderTarget(device, 0, rt_jfa_b_surf.?);
deviceSetTexture(device, 0, rt_jfa_a_tex);
c0 = .{ 2.0 / fw, 2.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
// step=1 (JFA_B → JFA_A)
deviceSetRenderTarget(device, 0, rt_jfa_a_surf.?);
deviceSetTexture(device, 0, rt_jfa_b_tex);
c0 = .{ 1.0 / fw, 1.0 / fh, 0.0, 0.0 };
deviceSetPSConstF(device, 0, &c0);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
@@ -1051,6 +1131,8 @@ fn runJfaPipeline(device: *anyopaque) void {
deviceSetRS(device, types.D3DRS.DESTBLEND, types.D3DBLEND_INVSRCALPHA);
deviceDrawPrimitiveUP(device, types.D3DPT_TRIANGLESTRIP, 2, @ptrCast(&quad), qstride);
} // end else (normal JFA path)
// =====================================================================
// Restore ALL state
// =====================================================================