From 215343ab39c4caf2dbca8ed83ac7cc08dc7d52e7 Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 9 Nov 2026 15:23:10 +0100 Subject: [PATCH] A draw that samples the depth it writes draws into a copy of it: Ocarina of Time's snow no longer flickers with black 16x16 blocks The snow draws (55845639d605e028, ee567dea65288f58 and others) test depth LEQUAL with writes while their fragment shader samples that depth through a texture slot; Metal leaves the read undefined and Apple's GPUs returned whole 16x16 tiles of another state. fb_prepare now asks such a draw (a fragment texture slot or bindless handle naming the depth it writes) for a pass whose depth attachment is a copy (g_dswap): rp_begin_ex_ copies the depth in, the draw writes the copy and samples the original, and rp_end_at copies it back, with full barriers. Pool and dynamic reads are left out (they may name any texture). DM_NVN_DEPTH_SWAP=0 turns it off. New GPU test t_depth_swap (19 checks, at the default and off); reference entry "A draw that samples the depth it writes". Co-Authored-By: Claude Opus 5.5 --- dm/games/0400486024DEC000/README.md | 1 + dm/gfx/nvn/nvn_mtl.m | 44 ++++++++++++++ dm/gfx/tests/Makefile | 6 ++ dm/gfx/tests/t_depth_swap.m | 94 +++++++++++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/env_switches.tsv | 1 + docs/reference/rendering.md | 10 +++ 7 files changed, 157 insertions(+) create mode 100644 dm/gfx/tests/t_depth_swap.m diff --git a/dm/games/0400486024DEC000/README.md b/dm/games/0400486024DEC000/README.md index d880588ffe..a1b2f8e110 100644 --- a/dm/games/0400486024DEC000/README.md +++ b/dm/games/0400486024DEC000/README.md @@ -109,6 +109,7 @@ From an empty save: at about 45 s `A` (title), `A` (New Game), then the movie. F ## Found on this game +- [A draw that samples the depth it writes](../../../docs/reference/rendering.md#a-draw-that-samples-the-depth-it-writes): resolved, black 16×16 blocks flickering where the snow meets rock and walls (the snow draws `55845639d605e028` and `ee567dea65288f58` sample the depth they write). - [The shader package and the shader map come from different splits](../../../docs/reference/packing.md#the-shader-package-and-the-shader-map-come-from-different-splits): resolved, a black screen with sound in a build through NX2Apple ([issue #1841](https://github.com/seedds/nxnative/issues/1841)); the present pass was translated from a terrain shader's record. Records now carry their keys, and the shader build finds them by key. - [Native mesh pipelines compiled at their first draw](../../../docs/reference/shaders.md#native-mesh-pipelines-compiled-at-their-first-draw): resolved. The foliage, shadows and enemies drawn with task and mesh shaders stalled at their first draw on a cold Metal cache (59.5 s of builds at the Kokiri Forest entry on an M5 Max, one frame of 42.9 s), as nothing recorded or kept their pipelines; `pso_manifest.tsv` now carries 123 `M` rows (Kokiri Forest, Hyrule Field, Castle Town, Hyrule Castle), and with the prepare step they are built before the first frame. Pipelines outside the list still stall once, then go into `pso_user.tsv`. - [One Pro Controller read as two players](../../../docs/reference/services.md#one-pro-controller-read-as-two-players): resolved, no music in Kokiri Forest gameplay and its cutscenes; the controller applet opened over and over and the game kept its music suspended while it had focus. diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 7ca6eecce9..cb86535153 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -4912,6 +4912,13 @@ static void rt_mirror_flush(void) { } if (g_blit) { enc_b_end(g_blit); g_blit = nil; } } +// A draw that writes the depth attachment while a texture slot or bindless handle of its fragment shader names that depth reads what Metal leaves undefined: +// on Apple's GPUs whole 16x16 tiles of another state come back, where NVN reads the depth from before the draw. Ocarina of Time's snow (55845639d605e028, ee567dea65288f58 and +// others: depth LEQUAL with writes, a depth fade sampled through a texture slot) drew flickering black blocks where it met rock and walls. Such a draw gets a pass of its own whose depth +// attachment is a copy of the depth (g_dswap): it writes the copy and reads the original, which nothing changes until the pass ends and the copy goes back. DM_NVN_DEPTH_SWAP=0: off +static int g_dswap_want, g_dswap_on; static __strong id g_dswap_src, g_dswap_tex; static uint64_t g_dswap_n; +static int dswap_enabled(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_NVN_DEPTH_SWAP"); v = !(e && !strcmp(e, "0")); if (!v) MLOG("depth read while written: no copy of the attachment (DM_NVN_DEPTH_SWAP=0)"); } return v; } +static void dswap_back(void); static void rp_end_at(const char *fn, int line) { if (g_enc) { AP_B; geo_mesh_flush_pass(); PD("E %llu pass ends in %s:%d", g_stat_pass, fn, line); if (enc_label_on()) { static uint64_t lf = ~0ull, base; uint64_t f = atomic_load(&g_cur_frame); if (f != lf) { lf = f; base = g_stat_pass - 1; } // Intra-frame serial: the same pass has the same name every frame, so the table can aggregate by name [(id)g_enc setLabel:[NSString stringWithFormat:@"p%02llu %dx%d %s d%d", (unsigned long long)(g_stat_pass - base), g_el_w, g_el_h, g_el_key[0] ? g_el_key : "-", g_el_draws]]; } @@ -4920,6 +4927,7 @@ static void rt_mirror_flush(void) { #endif sd_finish(); enc_r_end_pass(g_enc); g_gp_bw_l0 = g_bw_load; g_gp_bw_s0 = g_bw_store; // the profile's per-pass ledger counts from the previous pass end, profiled or not zskip_commit_clears(); g_enc = nil; g_enc_geo_nocolor = 0; g_rt_merged = 0; g_sm_pass = 0; g_rt_range_active = g_rt_range_pending = 0; pg_end_render(); ts_after(); ms_after(); if (g_lp.count) lp_finish(); fz_end(); + if (g_dswap_on) dswap_back(); // the depth this pass wrote into the copy goes back to the attachment if (g_nubd && g_nubd >= ubd_lazy()) ubd_flush(); // the inline updates deferred in this pass, in order (see mtl_sub_update); across passes they wait for the next copy or dispatch (blit_raw) if (g_nbcrt) bcrt_flush(); // blocks drawn into shadows go back into their compressed textures if (g_nrtm) rt_mirror_flush(); @@ -5061,6 +5069,12 @@ static void attach(MTLRenderPassAttachmentDescriptor *a, const tex_t *t, const v // (Mac character select screen: the color-clear pass drags along a 400x225 depth load and store) static id rp_begin_ex(int clear_only); static NSUInteger g_rp_aw; static int g_rp_whole; +static void dswap_back(void) { + id src = g_dswap_src, cp = g_dswap_tex; g_dswap_on = 0; g_dswap_src = nil; + if (!src || !cp) return; + id be = blit(); enc_b_copy_texture(be, cp, 0, 0, MTLOriginMake(0, 0, 0), MTLSizeMake(src.width, src.height, 1), src, 0, 0, MTLOriginMake(0, 0, 0)); + g_game_barrier = 1; // the next pass waits for it too +} static id rp_begin(void) { if (g_enc && g_rp_aw) rp_end(); g_rp_whole = 1; id e = rp_begin_ex(0); g_rp_whole = 0; return e; } // the callers draw anywhere in the target (see g_rp_aw) static id rp_begin_ex_(int clear_only); static id rp_begin_ex(int clear_only) { AP_B; id e = rp_begin_ex_(clear_only); AP_E(1); return e; } @@ -5076,6 +5090,7 @@ static void attach(MTLRenderPassAttachmentDescriptor *a, const tex_t *t, const v if (rt_range_on()) { reg0 = g_reg_gen; ser0 = g_ser_gen; } if (rt_range_on()) dirty0=nvn_dirty_now(); if (g_blit) { enc_b_end(g_blit); g_blit = nil; } + int dswap = g_dswap_want; g_dswap_want = 0; // asked by the draw opening this pass (fb_prepare), for this pass only MTL4RenderPassDescriptor *pd = [MTL4RenderPassDescriptor new]; int any = 0; for (int i = 0; i < R.rt.n; i++) { @@ -5119,6 +5134,22 @@ static void attach(MTLRenderPassAttachmentDescriptor *a, const tex_t *t, const v if (R.rt.depth && clear_only != 2 && !(clear_only == 1 && !R.clear_d && !R.clear_s)) { attach(pd.depthAttachment, R.rt.depth, &R.rt.dview, R.rt.has_dview); id t = pd.depthAttachment.texture; + if (dswap) { + if (t && mtl_fmt_depth(t.pixelFormat) && !R.clear_d && !t.parentTexture && t.textureType == MTLTextureType2D && t.sampleCount == 1 && !pd.depthAttachment.level && !pd.depthAttachment.slice) { + if (!g_dswap_tex || g_dswap_tex.pixelFormat != t.pixelFormat || g_dswap_tex.width != t.width || g_dswap_tex.height != t.height) { + if (g_dswap_tex) defer_obj_release(g_dswap_tex); + MTLTextureDescriptor *d = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:t.pixelFormat width:t.width height:t.height mipmapped:NO]; + d.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; d.storageMode = MTLStorageModePrivate; + g_dswap_tex = [g_dev newTextureWithDescriptor:d]; if (g_dswap_tex) res_add(g_dswap_tex); } + if (g_dswap_tex) { + id be = blit(); enc_b_copy_texture(be, t, 0, 0, MTLOriginMake(0, 0, 0), MTLSizeMake(t.width, t.height, 1), g_dswap_tex, 0, 0, MTLOriginMake(0, 0, 0)); + enc_b_end(g_blit); g_blit = nil; g_game_barrier = 1; // the pass waits for the copy (enc_r_begin_pass) + g_dswap_src = t; g_dswap_on = 1; t = g_dswap_tex; pd.depthAttachment.texture = t; + if (!g_dswap_n++) MLOG("depth read while written: a draw whose texture slot or handle names the depth it writes draws into a copy of it (first at pass %llu; DM_NVN_DEPTH_SWAP)", g_stat_pass + 1); + PD("S %llu depth attachment ser=%llu swapped for a copy: a draw reads the depth it writes", g_stat_pass + 1, (unsigned long long)R.rt.depth->serial); + } + } else { static int once; if (!once++) MLOG("depth read while written: the attachment is not a whole single-level 2D texture, or the pass clears it; left as is"); } + } MTLPixelFormat pf = t.pixelFormat; // Combined storage stays on both attachments: single-plane descriptors work // on macOS but iOS rejects Invalid / Depth32Float_Stencil8 at pipeline creation. @@ -12201,6 +12232,17 @@ static int fb_texu_reads(NvShader *fs, uint64_t ser) { if (s == ser) return 1; } } return 0; } +// Whether a sampled texture or read-only image slot of the fragment stage names this texture's storage (the depth attachment): read as fb_prepare reads the slots +static int fb_slot_reads(NvShader *fs, const tex_t *att) { + for (int i = 0; i < fs->rf.nb; i++) { const nvn_rbind *b = &fs->rf.b[i]; + int image = b->kind == NVN_RB_IMG && b->reg < 32; + if (!image && (b->kind != NVN_RB_TEX || b->reg >= NTEX)) continue; + uint64_t handle = image ? S.img[1][b->reg] : tex_slot_h(S.tex[1], S.img[1], b->reg); + int tid = (int)(handle & 0xfffff); if (!tid) continue; + os_unfair_lock_lock(&g_rmu); tex_t t = tid < g_ntreg && g_treg[tid].ok ? g_treg[tid].t : (tex_t){0}; os_unfair_lock_unlock(&g_rmu); + if (t.serial && tex_storage_overlap(&t, att)) return 1; } + return 0; +} // Called before the draw opens its render pass: if a snapshot is needed, end the current render pass, copy the snapshot (or reuse a valid one) and register the substitution (used by this draw's argument encoding) static void fb_prepare(NvProg *pr) { g_draw_seq++; g_fb_n = 0; @@ -12214,6 +12256,8 @@ static void fb_prepare(NvProg *pr) { int dsw = R.rt.depth && ((S.ds.dt && S.ds.dw) || (S.ds.st && (S.st_wmask[0] || S.st_wmask[1]) && (S.ds.fail_f != NVN_STENCIL_OP_KEEP || S.ds.zfail_f != NVN_STENCIL_OP_KEEP || S.ds.zpass_f != NVN_STENCIL_OP_KEEP || S.ds.fail_b != NVN_STENCIL_OP_KEEP || S.ds.zfail_b != NVN_STENCIL_OP_KEEP || S.ds.zpass_b != NVN_STENCIL_OP_KEEP))); if (g_enc && dsw && g_fb_dread) { g_fb_split++; g_fb_split_pool += g_fb_dread_pool; g_fb_dread_pool = 0; rp_end(); fb_pass_sync(); } // the same for its depth and stencil (see rtc_on) + if (dsw && R.rt.depth && S.ds.dt && S.ds.dw && !g_dswap_on && dswap_enabled() && (fb_slot_reads(fs, R.rt.depth) || (fs->rf.bindless && fb_texu_reads(fs, R.rt.depth->serial)))) { // a read the slots or handles prove: a pool or dynamic read may name any texture (pool_ds_read), and a copy for each would cost a pass and two copies a draw + if (g_enc) { rp_end(); fb_pass_sync(); } g_dswap_want = 1; } // it reads the depth it writes: its pass draws into a copy (g_dswap) if (R.rt.depth && !g_fb_dread && ((fs->rf.pool && pool_ds_read(R.rt.depth) && (g_fb_dread_pool = 1)) || fs->rf.dyn || (fs->rf.bindless && fb_texu_reads(fs, R.rt.depth->serial)))) g_fb_dread = 1; uint64_t tile[4]; int ntile = fb_tile_plan(pr, wm, tile); for (int i = 0; i < fs->rf.nb && g_fb_n < 4; i++) { diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index a76496f1e6..996dff6583 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -249,6 +249,8 @@ t_geometry: t_geometry.m $(MTL_T_DEP) check_t_geometry: RUN_ENV = MTL_DEBUG_LAYER=1 t_feedback: t_feedback.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_feedback.m $(MTL_T_SRC) $(FW) +t_depth_swap: t_depth_swap.m $(MTL_T_DEP) + clang $(CFLAGS) -fobjc-arc -o $@ t_depth_swap.m $(MTL_T_SRC) $(FW) t_rast_discard: t_rast_discard.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_rast_discard.m $(MTL_T_SRC) $(FW) t_queue_origin: t_queue_origin.m $(MTL_T_DEP) @@ -310,6 +312,10 @@ t_geometry_mesh: t_geometry_mesh.m t_geometry.m ../tools/dmsh_mesh.py $(MTL_T_DE .PHONY: check_t_geometry_mesh check_t_geometry_mesh: t_geometry_mesh @set -e; work=$$(mktemp -d); trap 'rm -rf "$$work"' EXIT; python3 test_dmsh_geometry.py --fixture "$$work"; MTL_DEBUG_LAYER=1 ./t_geometry_mesh "$$work"; DM_NVN_GEO_MESH_RUN=0 MTL_DEBUG_LAYER=1 ./t_geometry_mesh "$$work"; DM_NVN_ENC_THREAD=1 MTL_DEBUG_LAYER=1 ./t_geometry_mesh "$$work" +.PHONY: check_t_depth_swap +check_t_depth_swap: t_depth_swap + MTL_DEBUG_LAYER=1 ./t_depth_swap + DM_NVN_DEPTH_SWAP=0 MTL_DEBUG_LAYER=1 ./t_depth_swap .PHONY: check_t_feedback check_t_feedback: t_feedback @set -e; work=$$(mktemp -d); trap 'rm -rf "$$work"' EXIT; python3 test_dmsh_fetch.py --fixture "$$work"; MTL_DEBUG_LAYER=1 ./t_feedback "$$work" diff --git a/dm/gfx/tests/t_depth_swap.m b/dm/gfx/tests/t_depth_swap.m new file mode 100644 index 0000000000..4210153402 --- /dev/null +++ b/dm/gfx/tests/t_depth_swap.m @@ -0,0 +1,94 @@ +// A draw that writes the depth attachment while a fragment texture slot names that depth draws into a copy (g_dswap in nvn_mtl.m): fb_prepare asks for it, the pass the draw +// opens attaches the copy, and the pass end copies it back, so the depth the draw wrote reaches the attachment. A draw that writes depth with no read of it, and one that reads it +// with no depth write, keep the attachment. With DM_NVN_DEPTH_SWAP=0 no draw takes the copy, and the depth is written all the same. +#include "../nvn/nvn_mtl.m" +void *front_metal_layer(void) { return NULL; } +module_t *module_of(const void *p) { (void)p; return NULL; } +module_t *g_mod; int g_nmod; +void svc_thread_dump(void) {} +uint64_t now_ns(void) { return bp_now(); } +static int checks, failed; +#define CHECK(c, label) do { checks++; if (!(c)) { failed++; fprintf(stderr, "FAIL: %s\n", label); } } while (0) + +static id make(tex_t *t, uint64_t serial, int format) { + *t = (tex_t){.magic=MAGIC('T','E','X',0), .serial=serial}; + t->d = (nvn_texdesc){.target=NVN_TEXTURE_TARGET_2D, .format=format, .width=4, .height=4, .depth=1, .levels=1, .samples=1}; + MTLTextureDescriptor *d = tex_desc(t, 0); + id texture = [g_dev newTextureWithDescriptor:d]; + CHECK(texture != nil, "texture created"); + res_add(texture); mtl_tex_init(t); serial_put(serial, texture); + return texture; +} +// The depth of every texel, read back after the submission +static int depth_is(id depth, float want, const char *label) { + id b = [g_dev newBufferWithLength:1024 options:MTLResourceStorageModeShared]; res_add(b); + enc_b_copy_texture_buffer(blit(), depth, 0, 0, MTLOriginMake(0, 0, 0), MTLSizeMake(4, 4, 1), b, 0, 256, 1024); + CHECK(mtl_wait(mtl_sub_commit(), 5000000000ull) && !mtl_last_commit_error(), "GPU submission completed without error"); + int ok = 1; for (int y = 0; y < 4; y++) for (int x = 0; x < 4; x++) ok &= ((const float *)((const uint8_t *)b.contents + y * 256))[x] == want; + CHECK(ok, label); + mtl_sub_begin(); + return ok; +} + +int main(void) { @autoreleasepool { + setenv("DM_NVN_TBDR", "0", 1); setenv("DM_NVN_GPU_TS", "segment", 1); + const char *e = getenv("DM_NVN_DEPTH_SWAP"); int swap = !(e && !strcmp(e, "0")); + mtl_init(); NSError *error = nil; + id lib = [g_dev newLibraryWithSource:@"#include \nusing namespace metal;\n" + "struct V { float4 p[[position]]; };\n" + "vertex V v(uint i[[vertex_id]]) { V o; o.p = float4(i == 1 ? 3.0 : -1.0, i == 2 ? 3.0 : -1.0, 0.25, 1); return o; }\n" + "fragment float4 f() { return float4(0, 1, 0, 1); }\n" options:nil error:&error]; + CHECK(lib != nil, "synthetic shader compiles"); + if (!lib) { fprintf(stderr, "%s\n", error.localizedDescription.UTF8String); return 1; } + MTLRenderPipelineDescriptor *pdesc = [MTLRenderPipelineDescriptor new]; + pdesc.vertexFunction = [lib newFunctionWithName:@"v"]; pdesc.fragmentFunction = [lib newFunctionWithName:@"f"]; + pdesc.colorAttachments[0].pixelFormat = MTLPixelFormatRGBA8Unorm; pdesc.depthAttachmentPixelFormat = MTLPixelFormatDepth32Float; + id pipeline = [g_dev newRenderPipelineStateWithDescriptor:pdesc error:&error]; + CHECK(pipeline != nil, "depth-writing pipeline compiles"); + if (!pipeline) { fprintf(stderr, "%s\n", error.localizedDescription.UTF8String); return 1; } + MTLDepthStencilDescriptor *dd = [MTLDepthStencilDescriptor new]; dd.depthCompareFunction = MTLCompareFunctionLessEqual; dd.depthWriteEnabled = YES; + id lequal = [g_dev newDepthStencilStateWithDescriptor:dd]; + + NvProg *pr = [NvProg new]; NvShader *fs = [NvShader new]; pr->sh[1] = fs; + fs->stage = 1; strcpy(fs->key, "0123456789abcdef"); + fs->rf.nb = 1; fs->rf.b[0] = (nvn_rbind){.kind=NVN_RB_TEX, .reg=0, .aid=0, .dtt=MTLTextureType2D}; + tex_t color, depth; make(&color, 99901, NVN_FORMAT_RGBA8); id dtex = make(&depth, 99902, NVN_FORMAT_DEPTH32F); + const int tid = 700; mtl_reg_tex(tid, &depth, NULL); + + state_reset(); memset(&R, 0, sizeof R); mtl_sub_begin(); + R.has_rt = 1; R.rt.n = 1; R.rt.color[0] = &color; R.rt.depth = &depth; S.chmask.mask = 0xffffffffu; + R.clear_c[0] = 1; R.cc[0] = MTLClearColorMake(0, 0, 0, 1); R.clear_d = 1; R.cd = 1.0; + CHECK(rp_begin() != nil, "the clearing pass opens"); rp_end(); + depth_is(dtex, 1.0f, "the depth starts cleared to 1"); + + // Written and read through a texture slot: a pass of its own on a copy, and the depth comes back at its end + S.ds.dt = 1; S.ds.dw = 1; S.ds.df = NVN_DEPTH_FUNC_LEQUAL; S.tex[1][0] = tid; + fb_prepare(pr); + CHECK(g_dswap_want == swap, swap ? "a draw reading the depth it writes asks for the copy" : "DM_NVN_DEPTH_SWAP=0: no copy asked for"); + CHECK(rp_begin() != nil, "the draw's pass opens"); + CHECK(g_dswap_on == swap && (!swap || (g_dswap_src == dtex && g_dswap_tex && g_dswap_tex != dtex)), swap ? "its pass attaches a copy of the depth" : "its pass attaches the depth itself"); + enc_r_pso(g_enc, pipeline); enc_r_dss(g_enc, lequal); enc_r_viewport(g_enc, (MTLViewport){0, 0, 4, 4, 0, 1}); + enc_r_draw(g_enc, MTLPrimitiveTypeTriangle, 0, 3); + rp_end(); + CHECK(!g_dswap_on && !g_dswap_src, "the pass end hands the copy back"); + depth_is(dtex, 0.25f, "the depth the draw wrote reaches the attachment"); + + // Written with no read of it, and read with no write: the attachment as it is + S.tex[1][0] = 0; fb_prepare(pr); + CHECK(!g_dswap_want, "a draw writing depth it does not read asks for no copy"); + S.tex[1][0] = tid; S.ds.dw = 0; fb_prepare(pr); + CHECK(!g_dswap_want, "a draw reading depth it does not write asks for no copy"); + CHECK(rp_begin() != nil && !g_dswap_on, "its pass attaches the depth itself"); + rp_end(); + + // A request no pass with a depth attachment takes is not left for a later pass + S.ds.dw = 1; fb_prepare(pr); R.rt.depth = NULL; + CHECK(rp_begin() != nil, "a pass without depth opens"); rp_end(); + R.rt.depth = &depth; S.tex[1][0] = 0; S.ds.dw = 0; + CHECK(rp_begin() != nil && !g_dswap_on, "a later pass with the depth does not take the copy asked for before"); + rp_end(); + CHECK(mtl_wait(mtl_sub_commit(), 5000000000ull) && !mtl_last_commit_error(), "last submission completed without error"); + mtl_tex_release(&color); mtl_tex_release(&depth); + printf("depth swap (%s): %d checks, %d failed\n", swap ? "on" : "DM_NVN_DEPTH_SWAP=0", checks, failed); + return failed != 0; +} } diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index c70cf79c2d..f840f91be2 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -685,6 +685,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a tile grid in Ultrahand and Recall highlights; an `EQUAL` depth test rejects fragments writing the same depth as a cleared Depth16 target. → [Depth16 load-clears match fragment writes](../reference/rendering.md#depth16-load-clears-match-fragment-writes) - solid magenta blocks, exactly (255, 0, 255), hard-edged and aligned to the 16-pixel grid (16×16, sometimes two stacked): Super Mario 3D World and Super Mario Odyssey showed them only on the iPhone; Captain Toad and Origami King on a Mac, Apple M6, Captain Toad in about half the frames of the opening course, moving with the falling leaves. On an RG11B10F target (no alpha) the blocks are pure black or neon green, (0, 252, 0), and flicker, sometimes only part of a tile: Super Mario Bros. Wonder on an Apple M6 and an M1, most visible on the speech bubbles of dialogue; or non-finite pixels that bloom spreads into green blobs (NieR:Automata on the Mac). A rendered cutscene alternates brightness every game frame, with blocky bands of light and shadow switching on and off: each lighting-zone bit flips every frame (Xenoblade Chronicles 3, a read-only image load). → [Same-pass feedback read through a snapshot](../reference/rendering.md#same-pass-feedback-read-through-a-snapshot) - a scene full of deferred decals runs far below its target where the same scene with `DM_NVN_FB_SNAP=0` holds it, and the log's `same-pass feedback sampling: snapshot #…` count climbs by hundreds a frame. Tears of the Kingdom's first shrine on an iPad Pro 11 (M5): GPU p50 58.8 ms, about 15 fps, about 284 snapshots a frame (seedds/nxnative#737); Breath of the Wild on the Great Plateau, camera toward the Temple of Time, on an iPad Pro 13 (M4): 60 → about 42 fps, about 45 one-draw passes more a frame on the 1920×1080 G-buffer. Splatoon Raiders in inked stages on a MacBook Air (M2): GPU p50 22–26 ms against 11–14 ms in the hub, a one-draw pass and a whole-map snapshot of its 6400×6400 ink map (80 MB) for each ink stamp (seedds/nxnative#1063). The `[nvn] same-pass feedback up to frame …` line (every 300 frames) gives the snapshots, reuses and tile reads per frame. → [Same-pass feedback read from the tile](../reference/rendering.md#same-pass-feedback-read-from-the-tile) +- black or dark 16×16 blocks aligned to the screen grid where snow meets rock and walls, in different places every frame. The blocks show what lies behind the snow, as if the snow had failed its depth test there. Ocarina of Time (`0400486024DEC000`, v131072) in its snowy areas, on an M3 Max; the snow in front of the frozen walls and on the stairs. → [A draw that samples the depth it writes](../reference/rendering.md#a-draw-that-samples-the-depth-it-writes) - the `[nvn] residency set up to frame …` line (every 300 frames) shows hundreds to thousands of additions from `fb_snap`'s line in `nvn_mtl.m`, gigabytes of them, and as many removals, with the residency commits' time and the submit thread's kernel time up. Tears of the Kingdom on an iPhone 17 (1.4.2, seedds/nxnative#931): 1,500-1,600 additions of 2.4 MB textures every 300 frames, residency commits 250-600 ms every 300 frames against about 25 in an older build, kernel time on the submit thread 1-2 ms a frame against 0.2. → [Snapshot textures taken by format and size](../reference/rendering.md#snapshot-textures-taken-by-format-and-size) - a scene with many bindless decals runs well below its target with the GPU busy, and a pass dump (`DM_NVN_PASSDUMP`) shows long runs of one-draw passes on the same targets, each loading and storing every attachment. Fire Emblem Fortune's Weave's battle maps: 31 consecutive one-draw passes on four 1024×576 targets a frame, 126.5 passes and about 1.4 GB of attachment loads and stores a frame. → [Bindless shaders taken as depth readers](../reference/rendering.md#bindless-shaders-taken-as-depth-readers) - a game that reads textures through a pool runs slower than its draws explain, with passes of one draw each on the same targets. A pass dump (`DM_NVN_PASSDUMP`) shows `SetRenderTargets` going on in the open pass and the pass ending anyway. Ocarina of Time in Kokiri Forest: two frames of four had 219 passes, 127 of them on the 128x128 reflection probe's cube faces, and frame times of 15, 15, 18, 18.5 ms in a cycle. → [Pool shaders taken as depth readers](../reference/rendering.md#pool-shaders-taken-as-depth-readers) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index a1e62374ae..7630793e6b 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -133,6 +133,7 @@ DM_NVN_CUT_BUSY switch `=0`: a segment boundary commits whatever the GPU is doin DM_NVN_DEFER switch `0` turns off the deferred pass merging of the TBDR optimizations (which run only with `DM_NVN_TBDR=1`) DM_NVN_DEFER_AB diagnostic `=1` keeps the deferred pass merging (with `DM_NVN_TBDR=1`) off until the second screenshot has been written, then turns it on, to compare the pictures with and without it DM_NVN_DEPTHOVR diagnostic `=,…`: those draws compare depth with `ALWAYS`; with `DM_NVN_HILITE`, tells "the depth test rejects every fragment" from "nothing rasterizes" +DM_NVN_DEPTH_SWAP switch `0` turns off the depth copy for a draw that writes the depth attachment while a texture slot or bindless handle of its fragment shader names it (on by default): such a draw gets a pass whose depth attachment is a copy, which goes back at the pass end DM_NVN_DIRECT_UPLOAD switch 0 disables bounded direct RGBA16F array uploads and keeps staged copies; on by default for supported non-sparse textures. DM_NVN_DIRTYCHECK diagnostic `=1` fully hashes pool content on every use to find changes the dirty-page tracking missed DM_NVN_DISPATCH_OWN switch `=1` gives every compute dispatch a compute encoder of its own with full barriers, as before (by default dispatches share the copy encoder); `DM_NVN_PASSGPU` does the same diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index 4d1e13f15d..1e16f7b4d0 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -553,6 +553,16 @@ The replay on Metal (`dm/gfx/nvn/nvn_mtl.m`) and the Vulkan and OpenGL layers. E - [Splatoon Raiders](../../dm/games/0400380020056000/README.md#rendering): the ink stamps `6a5b76a60670e513` on the 6400×6400 RG8 ink map, reported with a first patch in #1063 by David Padron-Harris. - **Note:** on a Mac (Apple M4 Pro) Breath of the Wild's outdoor save with the camera turning (frames 3,000–7,000, two runs each): snapshots 28.6 → 4.5 a frame, attachment traffic 666 / 596 → 354 / 344 MB load / store a frame, render passes p50 146 → 141, GPU span (first start to last end) p50 12.15 → 11.84 ms, GPU time p50 8.87 → 8.78 ms; the Mac's pass turnaround is small, so the gain there is small. The iPad cases are not measured here. The snapshots left a frame are draws whose samples are not projective (`b2f5ae9ef79011b9` on attachment 5, `7636e1d2542c221a`). Splatoon Raiders on the Mac (M4 Pro), the same scripted route to the first beach twice, frames 7,500–14,000: `DM_NVN_FB_TILE=0` 4.2 snapshots a frame and GPU p50 12.78 ms (windows of 300 frames, median), tile reads 0.6 snapshots, about 111 tile reads a frame (the stamps `6a5b76a60670e513` and `649e8d19b45094e7`) and GPU p50 9.50 ms; screenshots at the same moments differ only in animated water, smoke and the character. Breath of the Wild has 38 such shaders: 5 → 4 snapshots a frame and 71 → 0 draws reusing one, the same 293 tile reads. The reporter's heavier inked stage (22–26 ms of GPU on an M2) is not reproduced here. +### A draw that samples the depth it writes + +- **Symptom:** black or dark 16×16 blocks aligned to the screen grid where snow meets rock and walls, in different places every frame. The blocks show what lies behind the snow, as if the snow had failed its depth test there. Ocarina of Time (`0400486024DEC000`, v131072) in its snowy areas, on an M3 Max; the snow in front of the frozen walls and on the stairs. +- **Cause:** the snow draws (`55845639d605e028`, tessellated, and `ee567dea65288f58`, among others) test depth LEQUAL with depth writes on, and their fragment shader samples that same depth texture through a bound texture slot (a depth fade against what lies under the snow). Sampling an attachment the draw writes is undefined in Metal: on Apple's GPUs whole tiles of another state come back, as for [colour attachments](#same-pass-feedback-read-through-a-snapshot). NVN gives the draw the depth from before it. `fb_prepare` already ended the pass before such a draw when an earlier draw of the pass had read the depth, but the draw's own read of what it writes stayed undefined. +- **Fix:** `fb_prepare` and `rp_begin_ex_` in `nvn_mtl.m`, for every game. A draw that writes depth while a fragment texture slot (`fb_slot_reads`) or a bindless handle (`fb_texu_reads`) names the depth attachment's storage gets a pass of its own whose depth attachment is a copy of the depth (`g_dswap`): the draw writes the copy and samples the original, which nothing changes until the pass ends; `rp_end_at` copies the depth back, and a full barrier orders both copies with the passes around them. A read through the texture pool or a dynamic index is not taken: it may name any texture, and Ocarina of Time keeps its depth textures in the pool every material shader reads (`pool_ds_read`). `DM_NVN_DEPTH_SWAP=0` turns it off. +- **Check:** `make -C dm/gfx/tests GAME= check_t_depth_swap` runs `t_depth_swap` through the production `fb_prepare`, `rp_begin` and `rp_end`, with the Metal API validation, once at the default and once with `DM_NVN_DEPTH_SWAP=0`: a draw writing depth whose texture slot names the depth takes the copy and its depth reaches the attachment, a draw writing depth it does not read and one reading depth it does not write keep the attachment, and a request no pass with a depth attachment takes is not left for a later pass (19 checks each, Apple M3 Max). +- **Found on:** + - [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md#found-on-this-game): the snow. Traced with `DM_NVN_TEXSNAP` copies of the 1536×864 RG11B10F scene after each pass: the blocks appeared in the passes of `55845639d605e028` and `ee567dea65288f58`, one draw each, each pass ended by `fb_prepare` before it; a temporary switch that turned their depth writes off removed the blocks where they draw. With the copy, the blocks are gone everywhere checked by eye, at a frame time p50 of 16.6 ms in the area (300-frame windows, M3 Max, not a matched A/B). +- **Note:** the cost is a pass of its own and two copies of the depth for each such draw (about 6.6 MB each for a 1536×864 depth-stencil). In Ocarina of Time's snowy areas that is about ten draws a frame. + ### Snapshot textures taken by format and size - **Symptom:** the `[nvn] residency set up to frame …` line (every 300 frames) shows hundreds to thousands of additions from `fb_snap`'s line in `nvn_mtl.m`, gigabytes of them, and as many removals, with the residency commits' time and the submit thread's kernel time up. Tears of the Kingdom on an iPhone 17 (1.4.2, [seedds/nxnative#931](https://github.com/seedds/nxnative/issues/931)): 1,500-1,600 additions of 2.4 MB textures every 300 frames, residency commits 250-600 ms every 300 frames against about 25 in an older build, kernel time on the submit thread 1-2 ms a frame against 0.2.