diff --git a/dm/games/010074F013262000/README.md b/dm/games/010074F013262000/README.md index f9bea6bbb0..86a318594d 100644 --- a/dm/games/010074F013262000/README.md +++ b/dm/games/010074F013262000/README.md @@ -179,11 +179,45 @@ closes with A; stand near an enemy to auto-attack and use the left stick to walk During a movie, Plus then a held X skips it. The diagnostic watchdog is off when `DM_WATCH` is absent; setting it to `0` still enables its default stall threshold. +## Rendering + +**Cutscene zone flicker (GitHub issue #1009, resolved).** Reported on an M4 Max +in Events Theater scenes 1–3. The lighting-zone shader `15816d836842bfb5` +(`shd_lgt.wishp`, bound as `15816d836842bfb5_b`) reads its own R32Uint colour +attachment as image slot 0 between barriers, to skip zones it has already +marked. The translated slot is `texture2d` with no access qualifier. An +open Metal pass returned the attachment from the end of the previous frame, so +each zone bit inverted on alternate frames. `fb_prepare` now snapshots a +read-only image of a colour attachment the same way it snapshots a texture +sample ([A read-only image load of a colour attachment](../../../docs/reference/rendering.md#a-read-only-image-load-of-a-colour-attachment)). + +Measured on an Apple M2 (8 GB) with two hosts that differ only by this change, +built at `5474c6f` (shader chain `d1043b780a74`, the chain of the libraries +built for this game), 48 consecutive distinct presents per burst. On chapter 1's +second scene, the battle with Colony Sigma (the thumbnail to the right of the +Prologue), the host before the change reverses every frame: one stretch +alternates about 38.3 and 40.6 (sign-flip fraction 1.00, even/odd gap 2.24, +within-group spread 0.52), another about 64 and 58 after a cut; 8 of 10 bursts +have a flip fraction of 0.83–1.00. The host with the change holds one level: +its highest flip fraction across 11 bursts is 0.28, and the stretch near 40 +steps by 0.12 on average. A milestone in its log names `15816d836842bfb5_b` +slot 0 on the snapshot path. The chapter 1 prologue does not show the +two-level step on either host. + +| run | frames, bursts left out | frame p50 | GPU p50 | fps | +|---|---|---|---|---| +| before | 13040–15307 | 53.2 ms | 50.4 ms | 18.3 | +| after | 5521–7913 | 55.2 ms | 53.0 ms | 17.2 | + +After CONTINUE the host with the change walks the first save's field (frame +p50 51.7 ms, GPU p50 49.9 ms, 19.1 fps). + ## Found on this game - [Monolith ARH1, XBC1 and HCPS/SLCT shader containers](../../../docs/reference/shaders.md#monolith-arh1-xbc1-and-hcpsslct-shader-containers). - [Subgroup operations rewritten for a one-lane subgroup](../../../docs/reference/shaders.md#subgroup-operations-rewritten-for-a-one-lane-subgroup). - [IMAGE usage does not split texture storage](../../../docs/reference/rendering.md#image-usage-does-not-split-texture-storage). +- [A read-only image load of a colour attachment](../../../docs/reference/rendering.md#a-read-only-image-load-of-a-colour-attachment). - [Rasterizer discard preserves vertex storage writes](../../../docs/reference/rendering.md#rasterizer-discard-preserves-vertex-storage-writes). - [Primitive barriers order vertex storage writes](../../../docs/reference/rendering.md#primitive-barriers-order-vertex-storage-writes). - [Transform feedback captured by a vertex entry point](../../../docs/reference/rendering.md#transform-feedback-captured-by-a-vertex-entry-point): interleaved outputs and geometry capture. diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 5258e4e75a..4d62033686 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -7488,6 +7488,9 @@ static int encode_args_(id en, NvShader *sh, int nvn_stage, i int tid = reg < 32 ? (int)(S.img[nvn_stage][reg] & 0xfffff) : 0; uint64_t trid; int real; id t = img_binding(tid, b, &trid, &real); + int snapped = 0; // same-pass feedback through a read-only image: the snapshot fb_prepare registered, matched on the texture img_binding just returned (a level/layer view stays that view) + if (real && !b->rw && !b->atom && g_fb_n && g_fb_seq == g_draw_seq && nvn_stage == 1) + for (int q = 0; q < g_fb_n; q++) if (t == g_fb_src[q]) { t = g_fb_use[q]; trid = g_fb_rid[q]; snapped = 1; break; } if (b->atom && !img_atomic_ok(t, b)) { // see dummy_img_atomic; an unbound slot's write sink is not atomic-capable either if (real) { id base = t.parentTexture ? t.parentTexture : t; RED_ONCE(93, "image atomics on a texture Metal cannot run them on (shader %s stage %d slot %d texture ID %d: Metal format %lu of base format %lu, type %lu for %u, %s): they go to a write sink", @@ -7499,7 +7502,7 @@ static int encode_args_(id en, NvShader *sh, int nvn_stage, i if (passdump()) PD("W %llu st%d reg%d tid=%d %s ser=%llu fmt=%lu %lux%lu view_sl=%lu array=%lu target=%lu shader wants target %u", g_stat_pass, nvn_stage, reg, tid, real ? "real" : "dummy", real && tid < g_ntreg ? g_treg[tid].t.serial : 0ull, (unsigned long)t.pixelFormat, (unsigned long)t.width, (unsigned long)t.height, (unsigned long)(t.parentTexture ? t.parentRelativeSlice : 0), (unsigned long)t.arrayLength, (unsigned long)t.textureType, (unsigned)b->dtt); slots[aid] = trid; - USE(t, MTLResourceUsageRead | MTLResourceUsageWrite); + USE(t, snapped ? MTLResourceUsageRead : MTLResourceUsageRead | MTLResourceUsageWrite); // the snapshot is shader-read only (fb_snap): a read-only image declares no write access (below) } else if (b->kind == NVN_RB_TBUF) { // Typed buffer: the buffer texture (target BUFFER) in texture slot r int tid = (int)(tex_slot_h(S.tex[nvn_stage], S.img[nvn_stage], reg) & 0xfffff); void *tv = NULL; uint64_t trid = tid ? tex_fast_rid(tid, &tv) : 0; __unsafe_unretained id t = (__bridge id)tv; @@ -9260,7 +9263,14 @@ void ms_after(void) { // visible to texture reads only through nvnCommandBufferBarrier, so a snapshot taken since the game's last barrier (and in this submission) holds everything the game may expect to read; later such // draws reuse it without ending the pass, unless the pass also read that attachment directly. Breath of the Wild's G-buffer decals write and sample their own targets, about 460 draws a frame outdoors: // a fresh snapshot for each took the Mac's GPU time from about 4 to 28 ms a frame, reuse until a barrier needs about ten a frame (GPU time within the noise of none). -// DM_NVN_FB_SNAP=0 none, all a fresh snapshot for every draw sampling an attachment of its pass (diagnostic) +// A read-only image load is the same rule. Xenoblade Chronicles 3's lighting zone mask (15816d836842bfb5, shd_lgt.wishp) reads its own R32Uint attachment as image slot 0 between barriers, to skip zones +// it has already marked. The translated slot is texture2d with no access qualifier (MSL's default access::sample; the reflection's rw stays 0, since that type contains neither access::write nor +// access::read_write), so the snapshot fb_snap makes with shader-read usage is a legal binding and is declared read-only for residency. Read straight from the attachment, an open pass shows the image +// the end of the previous frame instead of this pass's clear and draws, and each zone bit turns off and on every other frame: the flicker in rendered cutscenes. The texture compared with the attachment, +// and the one encode_args compares with g_fb_src, is the object img_binding would bind: a RegisterImage view already names one level and layer, and a swizzle-only binding is img_fit's identity view of +// that same range. A declared pixel class that needs a reinterpreting view is left as is (that view is a different object). An image read of an attachment nothing in the pass has written sets g_fb_rmask, +// so a later writer starts a new pass, as a texture read does. +// DM_NVN_FB_SNAP=0 none, all a fresh snapshot for every draw sampling or image-loading an attachment of its pass (diagnostic) static int g_fb_mode = -1; // 0 none, 1 writers with reuse until a barrier, 2 every sampling draw, fresh static uint64_t g_fb_bar, g_fb_reuse, g_fb_split; // game barriers so far (OP_BARRIER with bits); draws that reused a snapshot; passes ended before a write to an attachment an earlier draw read static uint64_t g_fb_pass = UINT64_MAX; static uint32_t g_fb_wmask, g_fb_rmask, g_fb_dread; // the current Metal render pass: color attachments written so far, sampled directly; a draw has read its depth attachment (or could have: bindless) @@ -9299,14 +9309,29 @@ static void fb_prepare(NvProg *pr) { || 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++; rp_end(); fb_pass_sync(); } // the same for its depth and stencil (see rtc_on) if (R.rt.depth && (fs->rf.bindless || fs->rf.dyn)) g_fb_dread = 1; - for (int i = 0; i < fs->rf.nb && g_fb_n < 4; i++) { const nvn_rbind *b = &fs->rf.b[i]; if (b->kind != NVN_RB_TEX || b->reg >= NTEX) continue; - int tid = (int)(tex_slot_h(S.tex[1], S.img[1], b->reg) & 0xfffff); if (!tid) continue; + for (int i = 0; i < fs->rf.nb && g_fb_n < 4; i++) { const nvn_rbind *b = &fs->rf.b[i]; + // Read-only image slots take the same snapshot as texture slots (see the note above g_fb_mode). reg < 32 is encode_args' own bound for S.img + int is_img = b->kind == NVN_RB_IMG && !b->rw && !b->atom && b->reg < 32; + if (!is_img && (b->kind != NVN_RB_TEX || b->reg >= NTEX)) continue; + int tid = is_img ? (int)(S.img[1][b->reg] & 0xfffff) : (int)(tex_slot_h(S.tex[1], S.img[1], b->reg) & 0xfffff); if (!tid) continue; os_unfair_lock_lock(&g_rmu); uint64_t ser = tid < g_ntreg && g_treg[tid].ok ? g_treg[tid].t.serial : 0; os_unfair_lock_unlock(&g_rmu); if (!ser) continue; if (R.rt.depth && R.rt.depth->serial == ser) g_fb_dread = 1; + // The image's texture is resolved once, the way encode_args will bind it, so a view of one level or layer is what the checks and g_fb_src use + id img_t = nil; + if (is_img) { + os_unfair_lock_lock(&g_rmu); + void *rawv = NULL; tex_fast_rid(tid, &rawv); id raw = (__bridge id)rawv; + int reinterpret = raw && b->dpf != NVN_DPF_FLOAT && mtl_pf_dpf(raw.pixelFormat) != b->dpf; // img_binding makes a class view here (img_fit); that object is not the attachment + if (!reinterpret) { uint64_t trid; int real; img_t = img_binding(tid, b, &trid, &real); if (!real) img_t = nil; } + os_unfair_lock_unlock(&g_rmu); + if (!img_t) continue; + } for (int k = 0; k < R.rt.n && k < 8 && g_fb_n < 4; k++) { if (!R.rt.color[k] || R.rt.color[k]->serial != ser) continue; uint64_t aser; int lv, sl; rt_sub(&R.rt, k, &aser, &lv, &sl); - os_unfair_lock_lock(&g_rmu); void *tv = NULL; tex_fast_rid(tid, &tv); id t = (__bridge id)tv; os_unfair_lock_unlock(&g_rmu); + id t; + if (is_img) t = img_t; + else { os_unfair_lock_lock(&g_rmu); void *tv = NULL; tex_fast_rid(tid, &tv); t = (__bridge id)tv; os_unfair_lock_unlock(&g_rmu); } if (!t) continue; int dup = 0; for (int q = 0; q < g_fb_n; q++) dup |= g_fb_src[q] == t; if (dup) continue; id base = t.parentTexture ? t.parentTexture : t; NSUInteger tl = t.parentTexture ? t.parentRelativeLevel : 0, ts = t.parentTexture ? t.parentRelativeSlice : 0; diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index ad37fb0c9d..f6a7eae70f 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -253,6 +253,8 @@ t_geometry_primitive_id: t_geometry_primitive_id.m t_geometry.m $(MTL_T_DEP) .PHONY: check_t_feedback check_t_feedback: t_feedback MTL_DEBUG_LAYER=1 ./t_feedback + DM_NVN_FB_SNAP=all MTL_DEBUG_LAYER=1 ./t_feedback --image + DM_NVN_FB_SNAP=0 MTL_DEBUG_LAYER=1 ./t_feedback --image t_mip_extent: t_mip_extent.m ../nvn/hle_nvn.c ../../host/crash.c ../../host/hle_bigstack.c t_host_stubs.c $(GFX) $(GFX_H) clang $(CFLAGS) -fobjc-arc -o $@ t_mip_extent.m ../../host/crash.c ../../host/hle_bigstack.c t_host_stubs.c $(filter-out ../nvn/nvn_mtl.m,$(GFX)) $(FW) .PHONY: check_t_mip_extent diff --git a/dm/gfx/tests/t_feedback.m b/dm/gfx/tests/t_feedback.m index 256279ed8c..609aaeb3b3 100644 --- a/dm/gfx/tests/t_feedback.m +++ b/dm/gfx/tests/t_feedback.m @@ -1,5 +1,5 @@ -// Feedback snapshots and their per-draw argument substitution include separate texture slots; a snapshot is reused until the game's next barrier, -// and a draw that only samples an attachment nothing in its pass has written reads it directly. +// Feedback snapshots and their per-draw argument substitution include separate texture slots and read-only image slots; a snapshot is reused until the game's next barrier, +// and a draw that only samples an attachment nothing in its pass has written reads it directly. An image load of one level of a texture is matched on that level. #include "../nvn/nvn_mtl.m" void *front_metal_layer(void) { return NULL; } module_t *module_of(const void *p) { (void)p; return NULL; } @@ -9,16 +9,21 @@ void svc_thread_dump(void) {} static int checks, failed; #define CHECK(c, label) do { checks++; if (!(c)) { failed++; fprintf(stderr, "FAIL: %s\n", label); } } while (0) -static id target(tex_t *t, uint64_t serial) { +static id target_fmt(tex_t *t, uint64_t serial, int format, int levels) { *t = (tex_t){.magic=MAGIC('T','E','X',0), .serial=serial}; - t->d = (nvn_texdesc){.target=NVN_TEXTURE_TARGET_2D, .format=NVN_FORMAT_RGBA8, - .width=4, .height=4, .depth=1, .levels=1, .samples=1}; + t->d = (nvn_texdesc){.target=NVN_TEXTURE_TARGET_2D, .format=format, + .width=4, .height=4, .depth=1, .levels=levels, .samples=1}; MTLTextureDescriptor *d = tex_desc(t, 0); d.storageMode = MTLStorageModeShared; id texture = [g_dev newTextureWithDescriptor:d]; CHECK(texture != nil, "render target created"); res_add(texture); mtl_tex_init(t); serial_put(serial, texture); return texture; } +static id target(tex_t *t, uint64_t serial) { return target_fmt(t, serial, NVN_FORMAT_RGBA8, 1); } +static int fb_env_mode(void) { + const char *e = getenv("DM_NVN_FB_SNAP"); + return e && !strcmp(e, "0") ? 0 : e && !strcmp(e, "all") ? 2 : 1; +} static void clear(MTLClearColor color) { R.clear_c[0] = 1; R.cc[0] = color; CHECK(rp_begin() != nil, "production render pass opens"); @@ -130,7 +135,79 @@ static void reuse_case(NvProg *pr) { printf("feedback reuse: %s\n", before == failed ? "PASS" : "FAIL"); mtl_tex_release(&source); } -int main(void) { @autoreleasepool { +// One R32Uint attachment. A finished pass stores 0x11. The next pass clears to 0x22 and a draw writes 0x33, then (after a game barrier) a fragment shader loads the same texel through a read-only image and writes the loaded value back. Switch semantics are 0x33, the +// earlier draw; an open Metal pass would load 0x11, the content from before the pass. DM_NVN_FB_SNAP=0 leaves the pass open and does not draw that load. +static void image_case(NvProg *writer, NvProg *reader, id old_p, id new_p, id read_p) { + int before = failed, mode = fb_env_mode(), tid = 760; + NvShader *rs = reader->sh[1]; + tex_t source; target_fmt(&source, 99820, NVN_FORMAT_R32UI, 2); + view_t level1 = {0}; level1.set_levels = 1; level1.base_level = 1; level1.nlevels = 1; + mtl_reg_image(tid, &source, &level1); + state_reset(); memset(&R, 0, sizeof R); mtl_sub_begin(); + R.has_rt = 1; R.rt.n = 1; R.rt.color[0] = &source; S.img[1][0] = tid; + R.clear_c[0] = 1; R.cc[0] = MTLClearColorMake(0x22, 0, 0, 1); CHECK(rp_begin() != nil, "level pass opens"); + fb_prepare(reader); + CHECK(g_fb_n == 0, "an image of level 1 is not feedback of the level 0 attachment"); + rp_end(); + R.rt.has_cview[0] = 1; R.rt.cview[0] = level1; + R.clear_c[0] = 1; CHECK(rp_begin() != nil, "level 1 pass opens"); + fb_prepare(reader); + CHECK(g_fb_mode == mode, "snapshot mode follows DM_NVN_FB_SNAP"); + CHECK(g_fb_n == (mode == 1 || mode == 2), "an image view of the attached level is snapshotted in the copying modes"); + rp_end(); + rs->rf.b[0].dpf = NVN_DPF_SINT; R.rt.has_cview[0] = 0; memset(&R.rt.cview[0], 0, sizeof R.rt.cview[0]); + mtl_reg_image(tid, &source, NULL); + R.clear_c[0] = 1; CHECK(rp_begin() != nil, "class pass opens"); + fb_prepare(reader); + CHECK(g_fb_n == 0, "an image whose pixel class needs a reinterpreting view is not snapshotted"); + rp_end(); + rs->rf.b[0].dpf = NVN_DPF_UINT; + // The value test is a one-level texture. The two-level one above would be left as a mip-chain view and never snapshotted. + tex_t pixels; id mask = target_fmt(&pixels, 99821, NVN_FORMAT_R32UI, 1); + mtl_reg_image(761, &pixels, NULL); S.img[1][0] = 761; R.rt.color[0] = &pixels; R.rt.has_cview[0] = 0; + if (mode == 1) { + R.clear_c[0] = 0; CHECK(rp_begin() != nil, "direct-read pass opens"); + S.chmask.mask = 0; fb_prepare(reader); + CHECK(g_fb_n == 0 && (g_fb_rmask & 1), "a read-only image of an unwritten attachment reads it directly and is remembered"); + S.chmask.mask = 0xffffffffu; fb_prepare(writer); + CHECK(!g_enc, "a later writer starts a new pass after that image read"); + } + // The value the pass must not return: stored, then left behind by the next pass's clear and draw. + S.chmask.mask = 0xffffffffu; + R.clear_c[0] = 0; CHECK(rp_begin() != nil, "stale pass opens"); + enc_r_pso(g_enc, old_p); enc_r_viewport(g_enc, (MTLViewport){0, 0, 4, 4, 0, 1}); enc_r_draw(g_enc, MTLPrimitiveTypeTriangle, 0, 3); rp_end(); + if (mode != 0) { + R.clear_c[0] = 1; R.cc[0] = MTLClearColorMake(0x22, 0, 0, 1); CHECK(rp_begin() != nil, "feedback pass opens on its clear"); + fb_prepare(writer); + enc_r_pso(g_enc, new_p); enc_r_viewport(g_enc, (MTLViewport){0, 0, 4, 4, 0, 1}); enc_r_draw(g_enc, MTLPrimitiveTypeTriangle, 0, 3); + g_fb_bar++; // the game's barrier between the mark and the load + fb_prepare(reader); + int closed = g_enc == nil, snaps = g_fb_n; + CHECK(closed, "the image load closes the pass that holds the earlier draw"); + CHECK(snaps == 1, "the image load binds one snapshot"); + R.clear_c[0] = 0; CHECK(rp_begin() != nil, "the load's pass opens on the stored attachment"); + CHECK(encode_args((id)g_enc, rs, 1, 0), "image arguments encoded"); + uint64_t bound = *(uint64_t *)((uint8_t *)rs->ab_buf.contents + rs->ab_off); + CHECK(snaps == 1 && bound == g_fb_rid[0] && bound != mask.gpuResourceID._impl, "the argument buffer holds the snapshot"); + enc_r_pso(g_enc, read_p); enc_r_viewport(g_enc, (MTLViewport){0, 0, 4, 4, 0, 1}); enc_r_draw(g_enc, MTLPrimitiveTypeTriangle, 0, 3); rp_end(); + id result = [g_dev newBufferWithLength:4096 options:MTLResourceStorageModeShared]; res_add(result); + readback(mask, result, 0); + CHECK(mtl_wait(mtl_sub_commit(), 5000000000ull), "GPU submission completed"); + CHECK(!mtl_last_commit_error(), "GPU submission has no error"); + int valid = 1; uint32_t got = *(const uint32_t *)result.contents; + for (int y = 0; y < 4; y++) for (int x = 0; x < 4; x++) valid &= *(const uint32_t *)((const uint8_t *)result.contents + y * 256 + x * 4) == 0x33; + if (!valid) fprintf(stderr, "image load saw %#x, want 0x33 (0x11 is the previous pass)\n", got); + CHECK(valid, "the image load sees the earlier draw, not the content from before the pass"); + } else { + fb_prepare(reader); + CHECK(g_fb_n == 0 && g_enc == nil, "snapshots off do not substitute and do not open a pass"); + CHECK(mtl_wait(mtl_sub_commit(), 5000000000ull), "GPU submission completed"); + CHECK(!mtl_last_commit_error(), "GPU submission has no error"); + } + printf("feedback image (mode %d): %s\n", mode, before == failed ? "PASS" : "FAIL"); + mtl_tex_release(&source); mtl_tex_release(&pixels); +} +int main(int argc, char **argv) { @autoreleasepool { setenv("DM_NVN_TBDR", "0", 1); mtl_init(); NSError *error = nil; id lib = [g_dev newLibraryWithSource:@"#include \nusing namespace metal;\n" @@ -150,9 +227,35 @@ int main(void) { @autoreleasepool { id pipeline = [g_dev newRenderPipelineStateWithDescriptor:d error:&error]; CHECK(pipeline != nil, "synthetic render pipeline compiles"); if (!pipeline) { fprintf(stderr, "%s\n", error.localizedDescription.UTF8String); return 1; } - const int slots[] = {0, 128, 255}; - for (int i = 0; i < 3; i++) slot_case(pr, pipeline, slots[i], i); - reuse_case(pr); + int image_only = argc > 1 && !strcmp(argv[1], "--image"); + if (!image_only) { + const int slots[] = {0, 128, 255}; + for (int i = 0; i < 3; i++) slot_case(pr, pipeline, slots[i], i); + reuse_case(pr); + } + id ilib = [g_dev newLibraryWithSource:@"#include \nusing namespace metal;\n" + "vertex float4 v(uint i[[vertex_id]]) { return float4(i == 1 ? 3.0 : -1.0, i == 2 ? 3.0 : -1.0, 0, 1); }\n" + "fragment uint4 fold(float4 p[[position]]) { return uint4(0x11u, 0, 0, 0); }\n" + "fragment uint4 fnew(float4 p[[position]]) { return uint4(0x33u, 0, 0, 0); }\n" + "struct A { texture2d img[[id(0)]]; };\n" + "fragment uint4 fread(float4 p[[position]], constant A& a[[buffer(0)]]) { return uint4(a.img.read(uint2(p.xy)).x, 0, 0, 0); }\n" + options:nil error:&error]; + CHECK(ilib != nil, "image shader compiles"); + if (!ilib) { fprintf(stderr, "%s\n", error.localizedDescription.UTF8String); return 1; } + NvProg *writer = [NvProg new]; NvShader *ws = [NvShader new]; writer->sh[1] = ws; ws->stage = 1; strcpy(ws->key, "imagewriter"); + NvProg *reader = [NvProg new]; NvShader *rs = [NvShader new]; reader->sh[1] = rs; rs->stage = 1; strcpy(rs->key, "15816d836842bfb5"); + rs->fn = [ilib newFunctionWithName:@"fread"]; rs->ae = [rs->fn newArgumentEncoderWithBufferIndex:0]; rs->arg_len = rs->ae.encodedLength; + rs->rf.nb = 1; rs->rf.b[0] = (nvn_rbind){.kind=NVN_RB_IMG, .aid=0, .dtt=MTLTextureType2D, .dpf=NVN_DPF_UINT}; + MTLRenderPipelineDescriptor *idsc = [MTLRenderPipelineDescriptor new]; + idsc.vertexFunction = [ilib newFunctionWithName:@"v"]; idsc.colorAttachments[0].pixelFormat = MTLPixelFormatR32Uint; + idsc.fragmentFunction = [ilib newFunctionWithName:@"fold"]; + id old_p = [g_dev newRenderPipelineStateWithDescriptor:idsc error:&error]; + idsc.fragmentFunction = [ilib newFunctionWithName:@"fnew"]; + id new_p = [g_dev newRenderPipelineStateWithDescriptor:idsc error:&error]; + idsc.fragmentFunction = rs->fn; + id read_p = [g_dev newRenderPipelineStateWithDescriptor:idsc error:&error]; + CHECK(old_p && new_p && read_p, "image pipelines compile"); + if (old_p && new_p && read_p) image_case(writer, reader, old_p, new_p, read_p); printf("feedback snapshot: %d checks, %d failed\n", checks, failed); return failed != 0; } } diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index b3263bfa6a..542deee892 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -510,6 +510,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - red lines for unimplemented mid-pass, partial-mask and partial-scissor clears (the clears were dropped); `depth-stencil clear with a scissor or partial stencil mask, cleared whole` (logged once). → [Clears inside a pass, with a partial mask or a partial scissor](../reference/rendering.md#clears-inside-a-pass-with-a-partial-mask-or-a-partial-scissor) - 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). → [Same-pass feedback read through a snapshot](../reference/rendering.md#same-pass-feedback-read-through-a-snapshot) +- a rendered cutscene alternates brightness every game frame. Each lighting-zone bit flips every frame, so the lit picture steps between two exposures. → [A read-only image load of a colour attachment](../reference/rendering.md#a-read-only-image-load-of-a-colour-attachment) - NaN texels in the upper levels of a texture the game fills level by level (each level drawn from the one above), and from there NaN wherever the texture is read: black speckles in whole 2×2 quads, white blotches once bloom spreads them, and a dark frame from the eye adaptation. → [Mip-chain pass sampling the level it renders](../reference/rendering.md#mip-chain-pass-sampling-the-level-it-renders) - text a game renders into textures for the title and the menus comes out upside down, while the scene looks right. → [Lower-left window origin](../reference/rendering.md#lower-left-window-origin) - geometry appears inside out and almost black; a fullscreen indirect-lighting triangle is entirely culled even though its vertex data and polygon state match the game. → [Queue initialization captures the raster origin](../reference/rendering.md#queue-initialization-captures-the-raster-origin) diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index 4b08f8f9e4..7d7ccbfd7a 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -449,7 +449,15 @@ The replay on Metal (`dm/gfx/nvn/nvn_mtl.m`) and the Vulkan and OpenGL layers. E - [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md#rendering): black and green blocks on the Mac (Apple M6; an M1 in [seedds/nxnative#377](https://github.com/seedds/nxnative/issues/377)) from its two feedback draws `a7e5c6fd501cf21f` and `22a61cb1ec732aef` on the RG11B10F scene, the same keys in v0 and 1.2.1, which had shown nothing on the iPhone. Screenshots with a flicker detector: v0's opening course 6 of 160 frames with blocks before, 0 of 150 with both listed; 1.2.1's Badge House dialogue 23 of 158 before, 0 of 158 after. - [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md): the cost case (below): over a thousand draws a frame sample a G-buffer target of their own pass, about 460 of them writing it. - [Paper Mario: The Origami King](../../dm/games/0100A3900C3E2000/README.md#rendering): magenta 16×16 blocks on the Mac (Apple M6) from its anti-aliasing pass `8dc20835c07cd7ff` (`shaders/aa.bfsha`) on the 1920×1080 RGB10A2 scene, the only shader `DM_NVN_FB_SNAP=all` snapshotted; about one block a frame at Peach's castle before, none with it listed. -- **Note:** the cost. Breath of the Wild's G-buffer decals write and sample their own targets (`4d3d5a4408c2e8d9`, `753d0169ae3ce4f0` and others on the 1600×900 G-buffer), about 460 such draws a frame outdoors on the Great Plateau; a fresh snapshot for each (`DM_NVN_FB_SNAP=all`) takes the Mac's GPU time from about 4 to 28 ms a frame (frame time 16.6 → 36.6 ms before). Between the game's barriers they need about ten snapshots a frame, and about 50 draws reuse each: GPU time p50 3.5–4.1 ms with `DM_NVN_FB_SNAP=0` and 3.7–4.5 ms with the rule over four runs each (2026-10-30, Apple M6, the outdoor save looking around, frames 4,800–6,900; the frame time stays at its 30 fps budget). The decals had shown no blocks on the iPhone; the snapshot makes them read what the Switch guarantees rather than what the tile happened to hold. `DM_NVN_FB_SNAP=all` with a screenshot detector shows whether blocks come from such a draw at all, and a pass dump (`N … same-pass feedback sampling` lines after the draw's `K` keys) names the shaders. +- **Note:** the cost. Breath of the Wild's G-buffer decals write and sample their own targets (`4d3d5a4408c2e8d9`, `753d0169ae3ce4f0` and others on the 1600×900 G-buffer), about 460 such draws a frame outdoors on the Great Plateau; a fresh snapshot for each (`DM_NVN_FB_SNAP=all`) takes the Mac's GPU time from about 4 to 28 ms a frame (frame time 16.6 → 36.6 ms before). Between the game's barriers they need about ten snapshots a frame, and about 50 draws reuse each: GPU time p50 3.5–4.1 ms with `DM_NVN_FB_SNAP=0` and 3.7–4.5 ms with the rule over four runs each (2026-10-30, Apple M6, the outdoor save looking around, frames 4,800–6,900; the frame time stays at its 30 fps budget). The decals had shown no blocks on the iPhone; the snapshot makes them read what the Switch guarantees rather than what the tile happened to hold. `DM_NVN_FB_SNAP=all` with a screenshot detector shows whether blocks come from such a draw at all, and a pass dump (`N … same-pass feedback sampling` lines after the draw's `K` keys) names the shaders. A read-only image load of an attachment is the same rule ([below](#a-read-only-image-load-of-a-colour-attachment)). + +### A read-only image load of a colour attachment + +- **Symptom:** a rendered cutscene alternates brightness every game frame. Each lighting-zone bit flips every frame, so the lit picture steps between two exposures. +- **Cause:** the fragment shader reads its own colour attachment through a read-only image slot between the game's barriers. Xenoblade Chronicles 3's lighting-zone shader `15816d836842bfb5` (`shd_lgt.wishp`; the bound variant is `15816d836842bfb5_b`) does this on image slot 0 of an R32Uint attachment. The translated slot is `texture2d` with no access qualifier, so MSL's default is `access::sample`. Reflection sets `rw` only when the type contains `access::write` or `access::read_write` (`nvn_refl.m`), and this slot's `rw` stays 0. The snapshot rule above looked at texture slots. Inside an open Metal render pass the image load therefore saw the attachment from before the pass, the end of the previous frame, rather than this pass's clear and earlier draws. +- **Fix:** `fb_prepare` in `nvn_mtl.m`, for every game. A read-only, non-atomic fragment image (`reg < 32`) whose binding is one level and layer of a colour attachment takes the same snapshot as a texture sample, and `encode_args` substitutes that snapshot for the draw. Both sides compare the object `img_binding` returns: a `RegisterImage` view already names one level and layer, and a swizzle-only binding is `img_fit`'s identity view of that same range. A declared pixel class that needs a reinterpreting view is left as is, because that view is a different object. The snapshot is created with shader-read usage, which is legal for `access::sample`, and the substituted image is declared `MTLResourceUsageRead`. An image read of an attachment nothing in the pass has written sets the direct-read mask, so a later writer starts a new pass. +- **Check:** `make -C dm/gfx/tests GAME= check_t_feedback` with `MTL_DEBUG_LAYER=1`. The image case covers modes 0, 1 and 2: a level-1 view of a two-level R32UI matches the level-1 attachment and skips level 0, a SINT class on R32Uint is skipped, a one-level read returns the earlier draw after a clear and a later write, and an image read of an unwritten attachment makes a later writer end the pass. On an Apple M2 (2026-11-03): mode 1, 102 checks, 0 failed, together with the existing slot and reuse cases; `DM_NVN_FB_SNAP=all --image`, 23 checks; `DM_NVN_FB_SNAP=0 --image`, 17. Mode `all` logs `shader 15816d836842bfb5 slot 0 samples attachment 0`. +- **Found on:** [Xenoblade Chronicles 3](../../dm/games/010074F013262000/README.md#rendering). ### Mip-chain pass sampling the level it renders