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
+239
View File
@@ -0,0 +1,239 @@
# Outline Subsystem
Screen-space outline rendering for WoW 1.12.1 (build 5875), injected as a 32-bit DLL
via Wine/DXVK on Linux. Outlines are rendered using a Jump Flood Algorithm (JFA) pipeline
that runs entirely in EndScene, composited on top of the final backbuffer.
## Architecture Overview
The outline system has three main phases per frame:
1. **Object scan** (EndScene start) — identify which game objects should be outlined
2. **DIP hook** (during game rendering) — cache draw calls and write stencil marks
3. **JFA pipeline** (EndScene, after game rendering) — produce and composite outlines
## Files
| File | Purpose |
|---|---|
| `api.zig` | Public API: `init()`, `cleanup()`, `setEnabled()`, Lua command handler |
| `d3d9_hook.zig` | D3D9 vtable hooks (EndScene, DIP, Reset), JFA pipeline, all shaders |
| `model_hook.zig` | WoW model render hooks, batch reordering, `rendering_outline` flag |
| `tracker.zig` | Per-frame object/model tracking, classification, color/width queries |
| `types.zig` | D3D9 constants, vtable indices, outline colors, model categories |
| `offsets.zig` | WoW memory addresses and struct offsets (object manager, functions) |
| `wow.zig` | Game memory access wrappers (object traversal, GUID resolution, unit helpers) |
## Render Order
The game's rendering pipeline processes geometry in this order. The outline system
controls M2 batch ordering via `model_hook.zig` and uses the resulting depth/stencil
state to determine outline visibility.
```
1. World geometry + WMOs (game engine, writes depth)
2. Game object M2s (doodads) (batch group 1, writes depth)
3. Local player M2s (batch group 1, writes depth)
4. Outline target M2s (batch group 2, DIP hook writes stencil)
5. Other players + gear + NPCs (batch group 3, renders normally)
6. EndScene (JFA pipeline composites outlines)
```
### Batch reordering (model_hook.zig)
`CM2SceneRenderDraw` receives a flat array of M2 batch indices. The hook partitions
them into 3 groups before calling the original function:
- **Group 1 — depth-priority models**: game object M2s and the local player's M2s.
These render first so their depth is in the buffer when stencil marks are written.
The local player occludes outlines (they're the camera reference point).
If the local player IS an outline target, their models go in group 2 instead
(the outline check takes priority in the partition logic).
- **Group 2 — outline targets**: models belonging to tracked entities (current target,
raid-marked units, dead friendly players). The DIP hook intercepts these draws to
cache parameters and write stencil=1 where they pass the depth test.
- **Group 3 — everything else**: other players, their gear, NPCs, creatures.
These render last. Their depth is NOT in the buffer when stencil marks are written,
so outlines show through them. The outline composites on top in EndScene regardless.
### What occludes outlines
| Geometry | Occludes outlines? | Why |
|---|---|---|
| World terrain, WMOs | Yes | Rendered before any M2s, depth already in buffer |
| Game objects (M2 doodads) | Yes | Batch group 1, depth written before stencil |
| Local player | Yes | Batch group 1, unless self-outlined |
| Other players + gear | No | Batch group 3, render after stencil is written |
| NPCs / creatures | No | Batch group 3 |
## Stencil System
The DIP hook writes stencil marks during outline target rendering (group 2):
- `STENCILFUNC = ALWAYS`, `STENCILPASS = REPLACE`, `STENCILREF = 1`
- `STENCILZFAIL = KEEP` — pixels behind depth-tested geometry keep stencil=0
- After each outline DIP, `STENCILWRITEMASK` is set to 0 to protect marks from
subsequent draws (group 3 models could otherwise overwrite them)
- Exception: dead players skip stencil entirely (`STENCILENABLE = 0`) so their
outlines are visible through walls for corpse finding
EndScene Phase 1 uses `STENCILFUNC = EQUAL`, `STENCILREF = 1` to gate the
silhouette replay — only pixels marked as visible get silhouette color.
Stencil is cleared to 0 after Phase 1 to avoid affecting the next frame.
## JFA Pipeline (EndScene Phase 2)
After Phase 1 produces the silhouette RT (A8R8G8B8), the JFA pipeline generates
outlines via distance field:
1. **JFA Init** — seed the distance field from the silhouette. Pixels with
silhouette content (alpha >= 0.002) output their own UV as a seed.
Empty pixels output sentinel (-1, -1) which is outside UV space [0,1]
so it never wins distance comparisons.
2. **JFA Propagation** — 4 passes at step sizes [8, 4, 2, 1], ping-ponging
between two G16R16F render targets. Each pass does a 9-tap sample
(self + 8 neighbors at step distance) and keeps the nearest seed UV.
3. **JFA Decode + Composite** — compute pixel-space distance from each pixel
to its nearest seed. If distance < outline width AND the pixel is outside
the silhouette interior, output the outline color with alpha blending.
### Outline widths
| Category | Pixels | Encoded alpha |
|---|---|---|
| Target | 2.25 | 0.5625 |
| Raid mark | 1.5 | 0.375 |
| Dead player | 2.5 | 0.625 |
Width is encoded as `alpha = pixels / 4.0` in the silhouette, decoded as
`width = alpha * 4.0` in the decode shader.
### Render targets
| RT | Format | Purpose |
|---|---|---|
| `rt_silhouette_tex` | A8R8G8B8 | Flat-color silhouettes with width-encoded alpha |
| `rt_jfa_a_tex` | G16R16F | JFA ping buffer (seed UV coordinates) |
| `rt_jfa_b_tex` | G16R16F | JFA pong buffer |
## DIP Hook — Draw Caching
The DIP hook does NOT draw silhouettes inline (that corrupts WoW's GxDevice
internal render state). Instead it:
1. Caches draw parameters (VB, IB, vertex decl, VS, VS constants, prim params)
2. AddRef's COM objects to keep them alive until EndScene
3. Writes stencil marks using the game's own depth buffer
4. Calls the original DIP exactly once (normal game rendering)
EndScene Phase 1 replays cached draws to the silhouette RT with:
- The flat-color pixel shader (outputs PS constant c0)
- The game's original vertex shader + VS constants (bone matrices, transforms)
- Stencil gating (stencil=1 required, except dead players)
- Depth testing disabled, depth writes disabled
## Object Tracking (tracker.zig)
Each frame, `scanObjects()` iterates the WoW object manager and collects:
- **Outline targets**: current target, raid-marked units/players, dead friendly players
- **Depth-priority objects**: game objects (type 5) and the local player
When `CM2Model_ManageRenderListNode` fires for each model being added to the
render list, `classifyModel()` reads the model's owner back-pointers
(`model+0x28` direct, `model+0x3C0` callback) and matches them against the
collected object pointers. No pointer dereferencing of unknown memory — just
value comparison against the validated set from the object manager.
## Outline Categories
| Category | Color | Trigger |
|---|---|---|
| `target` | Golden amber (#FFC800) | Current target |
| `raid_marked` | Per-mark color (8 colors) | Unit has raid mark 1-8 |
| `dead_player` | Cyan (#00FFFF) | Dead friendly player or non-skeleton corpse |
## Hook Installation
1. `api.init()` installs model hooks immediately (ManageRenderListNode,
DrawBatchProjected, CM2SceneRenderDraw)
2. D3D9 hooks are **deferred** until the first model hook fires — creating a
dummy D3D9 device during engine init corrupts the proxy's state
3. `api.initD3D9Deferred()` patches the D3D9 vtable (EndScene, DIP, Reset)
4. Reset hook forces D24S8 depth/stencil format (8 stencil bits required)
## Lua API
```lua
OutlineCommand() -- returns current enabled state (bool)
OutlineCommand("on") -- enable outlines
OutlineCommand("off") -- disable outlines
```
## Known Issues
- **JFA banding artifacts**: "marching ants" pattern in the JFA output, dependent
on screen-space position of the target. Confirmed the silhouette RT is clean
(no stale VB issue). Root cause is in the JFA propagation/decode shaders.
See `docs/jfa-banding-investigation.md` for full analysis.
- **Outline target gear not included**: equipment M2s (shoulders, weapons, helms)
belonging to the outline target are not currently part of the silhouette.
The outline follows the body mesh contour only. Tracking equipment models
requires identifying them via owner back-pointers (planned).
- **Local player outline (planned)**: a Gaussian blur outline mode for the local
player to improve visibility in combat when surrounded by mobs. Separate from
the JFA pipeline — will use blur difference (blur silhouette, subtract original,
threshold) for a softer glow effect and only apply to other players.
- **Death tracking needs improvement**: currently uses `UNIT_FLAG_DEAD` which is
too simple. Needed changes:
- Dead players should be outlined (works now)
- Released bodies (corpse objects) should also be outlined (partially works via `.corpse`
type, but needs verification that released-but-not-skeleton corpses are caught)
- Feign Death must NOT trigger the dead outline — feign death sets the dead flag
but the player is alive. Need to check for the feign death aura/buff or use a
more specific death condition
- The local player should never get a death outline on themselves — the purpose
of the death outline is to help the player find and resurrect others, not to
highlight their own corpse
- Marker outlines should not persist on units after they die
- Targeting a dead body should use a distinct outline color/style from the normal
target outline, a player body should have the corpse outline. Only group/raid member
bodies/corpses should outline!
- Skeleton corpses should not outline.
- **Shapeshift form breaks local player occlusion**: changing form (e.g. ghost wolf,
druid forms) causes the local player's model pointer to change. The new model
isn't matched against the local player's object pointer, so it falls into batch
group 3 instead of group 1 and no longer occludes outlines.
- **Mount + rider outline (planned)**: mounted players should outline the full
mount+rider+gear as one unit. Requires understanding the M2 attachment hierarchy
(mount is parent, rider is attached, gear is attached to rider?). All child models
(rider body, gear) should be included in the silhouette.
## Future Architecture Notes
- **Hook "should render" function for model tracking**: the perfboost system uses a
"should render" callback that receives a direct live object reference for every
model considered for rendering each frame. Hooking this instead of (or alongside)
`ManageRenderListNode` would give us a more reliable way to stay current on what
objects and models are in the scene, with a direct object reference we can inspect
for owner, form, mount status, etc. This could solve the shapeshift/mount model
tracking issues since we'd always have the live object to check against.
## Build
```sh
cd /media/storage/projects/zig/weirdutils
zig build
```
Target: x86-windows-msvc (32-bit DLL), Zig 0.15.
+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
// =====================================================================
+28 -17
View File
@@ -62,11 +62,14 @@ var reordered_indices: [MAX_REORDER]i32 = undefined;
// __thiscall(this, viewMatrix, batchData, batchIndices, batchCount)
// Native thiscall detour — no thunk needed.
//
// Reorders batch indices so outline targets draw LAST. Non-outline models
// (game objects, other characters, NPCs) render first, filling the depth
// buffer with full scene geometry. When outline targets then render, the
// DIP hook writes stencil marks against this complete depth buffer, so
// outlines are properly occluded by all scene objects (not just terrain/WMOs).
// Reorders batch indices into 3 groups:
// 1. Game objects/doodads — render first, write depth so outlines respect them
// 2. Outline targets — render second, DIP hook writes stencil against depth
// 3. Other players, gear, NPCs — render last, draw over targets normally
//
// This gives outlines that are occluded by world/WMO/game objects but show
// through other players and gear (since those aren't in depth when stencil
// is written). The outline composites on top of everything in EndScene.
fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) callconv(THISCALL) void {
// One-time: install D3D9 hooks now that the game is actively rendering.
@@ -83,13 +86,18 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
const indices: [*]i32 = @ptrFromInt(batch_indices);
// Pass 1: count outline targets
// Pass 1: count outline targets and game objects for partitioning
var outline_count: u32 = 0;
var game_obj_count: u32 = 0;
for (0..batch_count) |i| {
const idx_u: u32 = @bitCast(indices[i]);
const model_ptr = hook.readMem(u32, batch_data +% idx_u *% 0x40 +% 4);
if (model_ptr != 0 and tracker.findOutlineEntry(model_ptr) != null) {
outline_count += 1;
if (model_ptr != 0) {
if (tracker.findOutlineEntry(model_ptr) != null) {
outline_count += 1;
} else if (tracker.isGameObjectModel(model_ptr)) {
game_obj_count += 1;
}
}
}
@@ -98,13 +106,13 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
return;
}
// Pass 2: partition — non-outline models first, outline targets last.
// Rendering non-outline models first fills the depth buffer with game
// objects, other characters, etc., so outline target stencil marks
// respect full scene occlusion (not just terrain+WMO).
const normal_count = batch_count - outline_count;
var normal_pos: u32 = 0;
var outline_pos: u32 = normal_count;
// Pass 2: 3-way partition:
// [0 .. game_obj_count) → game objects (write depth first)
// [game_obj_count .. +outline) → outline targets (stencil against depth)
// [remainder ..] → other players, gear, NPCs
var go_pos: u32 = 0;
var outline_pos: u32 = game_obj_count;
var rest_pos: u32 = game_obj_count + outline_count;
for (0..batch_count) |i| {
const batch_idx = indices[i];
const idx_u: u32 = @bitCast(batch_idx);
@@ -112,9 +120,12 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
if (model_ptr != 0 and tracker.findOutlineEntry(model_ptr) != null) {
reordered_indices[outline_pos] = batch_idx;
outline_pos += 1;
} else if (model_ptr != 0 and tracker.isGameObjectModel(model_ptr)) {
reordered_indices[go_pos] = batch_idx;
go_pos += 1;
} else {
reordered_indices[normal_pos] = batch_idx;
normal_pos += 1;
reordered_indices[rest_pos] = batch_idx;
rest_pos += 1;
}
}
+56
View File
@@ -45,6 +45,21 @@ pub var tracked_obj_count: usize = 0;
var frame_outlines: [MAX_OUTLINE_MODELS]types.OutlineEntry = undefined;
var frame_outline_count: usize = 0;
// =============================================================================
// Per-frame game object tracking (for render ordering — game objects first)
// =============================================================================
// Game object M2 models need to render before outline targets so their depth
// is in the buffer when stencil marks are written. Tracked separately from
// outline entries since game objects don't get outlines.
const MAX_GAME_OBJ_MODELS = 256;
var game_obj_ptrs: [MAX_TRACKED_OBJS]u32 = undefined;
var game_obj_ptr_count: usize = 0;
var game_obj_models: [MAX_GAME_OBJ_MODELS]u32 = undefined;
var game_obj_model_count: usize = 0;
// =============================================================================
// Global enable flag
// =============================================================================
@@ -88,6 +103,14 @@ pub fn getModelCategory(model_ptr: u32) types.ModelCategory {
return entry.category;
}
/// Check if a model belongs to a game object (for render ordering).
pub fn isGameObjectModel(model_ptr: u32) bool {
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) return true;
}
return false;
}
/// Get screen-space outline thickness in pixels for a category.
pub fn getOutlinePixels(cat: types.ModelCategory) f32 {
return switch (cat) {
@@ -124,6 +147,24 @@ pub fn classifyModel(model_ptr: u32) void {
}
}
}
// Match against game object pointers (for render ordering).
if (game_obj_ptr_count > 0 and game_obj_model_count < MAX_GAME_OBJ_MODELS) {
for (game_obj_ptrs[0..game_obj_ptr_count]) |go_ptr| {
if (owner_callback == go_ptr or owner_direct == go_ptr) {
// Deduplicate
var found = false;
for (game_obj_models[0..game_obj_model_count]) |m| {
if (m == model_ptr) { found = true; break; }
}
if (!found) {
game_obj_models[game_obj_model_count] = model_ptr;
game_obj_model_count += 1;
}
break;
}
}
}
}
fn addOutlineEntry(model_ptr: u32, cat: types.ModelCategory, mark: u8) void {
@@ -155,12 +196,21 @@ pub fn scanObjects() void {
// Clear per-frame sets
frame_outline_count = 0;
tracked_obj_count = 0;
game_obj_ptr_count = 0;
game_obj_model_count = 0;
resetDiag();
if (!wow.isInGame()) return;
const local_player = wow.getLocalPlayer();
if (local_player == 0) return;
// Local player renders before outline targets (occludes outlines) unless
// the local player IS an outline target (partition logic checks outline first).
if (game_obj_ptr_count < MAX_TRACKED_OBJS) {
game_obj_ptrs[game_obj_ptr_count] = local_player;
game_obj_ptr_count += 1;
}
// Cache raid target GUIDs
wow.cacheRaidTargets();
@@ -197,6 +247,12 @@ pub fn scanObjects() void {
addTrackedObj(obj, .dead_player, 0);
}
},
.game_object => {
if (game_obj_ptr_count < MAX_TRACKED_OBJS) {
game_obj_ptrs[game_obj_ptr_count] = obj;
game_obj_ptr_count += 1;
}
},
else => {},
}
}