| /* | |
| * Copyright 2026 Rive | |
| * | |
| * GM test / Phase 4 witness for the post-review fix sweep | |
| * (`/Users/luigi/Projects/plan/Ore/Post-Review Fix Plan.md` Phase 4). | |
| * | |
| * Locks two latent upload-path bugs that the existing GMs all missed: | |
| * | |
| * 1. D3D12 subresource formula. The transposed pre-fix formula | |
| * `mipLevel * ArraySize + layer` only happens to coincide with the | |
| * correct `mipLevel + layer * MipLevels` when `MipLevels == 1` — | |
| * which every other Ore GM satisfies. This GM creates a 4×4 | |
| * `array2D` texture with **`numMipmaps = 2`** so the two formulas | |
| * diverge: the correct formula maps `(layer=1, mip=0)` to | |
| * subresource 2, the transposed formula maps it to subresource 1 | |
| * (mip 1 of layer 0) and silently mis-targets the upload. | |
| * | |
| * 2. GL `bytesPerRow` honoured via `GL_UNPACK_ROW_LENGTH`. We upload | |
| * each layer with a **non-tightly-packed** source (`bytesPerRow = | |
| * 32` bytes for a 4-pixel-wide RGBA8 row, which is the natural 16 | |
| * bytes plus 16 bytes of padding). Without the | |
| * `GL_UNPACK_ROW_LENGTH` plumbing, GL reads tightly and the | |
| * uploaded layer comes back as the first pixel repeated four | |
| * times in stripes (or just garbage) instead of the intended | |
| * solid-color square. | |
| * | |
| * 3. Partial-region uploads. Layer 0 is written as four 2×2 sub-rect | |
| * tiles, so it samples identically but every backend's sub-region | |
| * path runs. Witnesses the D3D12 full-subresource over-read (pre-fix | |
| * it read the whole mip out of each small tile buffer). | |
| * | |
| * Layer colors (mip 0): | |
| * layer 0 → red (255, 0, 0) | |
| * layer 1 → green ( 0, 255, 0) | |
| * layer 2 → blue ( 0, 0, 255) | |
| * layer 3 → yellow (255, 255, 0) | |
| * | |
| * The fragment shader maps NDC-space UV to a 2×2 grid of layers and | |
| * samples mip 0 via a `array2D` view. Sampler `maxLod = 0` keeps us | |
| * off mip 1 (which we deliberately never upload — the storage exists, | |
| * but D3D12 leaves it zero-initialised). | |
| * | |
| * Expected screen layout (NDC y-up — origin at bottom-left of the | |
| * sampled rect, so layer 0 lands in the bottom-left quadrant): | |
| * BL = layer 0 = red TL = layer 2 = blue | |
| * BR = layer 1 = green TR = layer 3 = yellow | |
| * | |
| * A regression that drops the GL `GL_UNPACK_ROW_LENGTH` plumbing shows | |
| * up as 0xCC stripes (the padding sentinel) intruding into the colour | |
| * — easy to spot. A regression that re-introduces the transposed | |
| * D3D12 subresource formula maps `(layer=N, mip=0)` to `(layer=0, | |
| * mip=N)` which is uninitialised mip-1 storage on D3D12 → at least one | |
| * quadrant comes back as the zero-init colour (mostly black with | |
| * format-dependent garbage). | |
| */ | |
| #include "gm.hpp" | |
| #include "gmutils.hpp" | |
| #include "ore_gm_helper.hpp" | |
| #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ | |
| defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ | |
| defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ | |
| defined(ORE_BACKEND_RHI) | |
| #include "rive/renderer/render_canvas.hpp" | |
| #include "rive/renderer/ore/ore_bind_group.hpp" | |
| #include "rive/renderer/ore/ore_buffer.hpp" | |
| #include "rive/renderer/ore/ore_texture.hpp" | |
| #include "rive/renderer/ore/ore_sampler.hpp" | |
| #include "rive/renderer/ore/ore_shader_module.hpp" | |
| #include "rive/renderer/ore/ore_pipeline.hpp" | |
| #include "rive/renderer/ore/ore_render_pass.hpp" | |
| #endif | |
| using namespace rivegm; | |
| using namespace rive; | |
| using namespace rive::gpu; | |
| #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ | |
| defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ | |
| defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ | |
| defined(ORE_BACKEND_RHI) | |
| using namespace rive::ore; | |
| #define ORE_ARRAY_UPLOAD_ACTIVE | |
| #endif | |
| #ifdef ORE_ARRAY_UPLOAD_ACTIVE | |
| // 4×4 RGBA8, with bytesPerRow = 32 (16 valid + 16 padding) so the upload | |
| // exercises GL_UNPACK_ROW_LENGTH on the GL backend. Padding bytes are | |
| // filled with 0xCC so a regression that ignores bytesPerRow shows up as | |
| // brown stripes instead of the intended solid colour. | |
| static constexpr uint32_t kLayerWidth = 4; | |
| static constexpr uint32_t kLayerHeight = 4; | |
| static constexpr uint32_t kPaddedRowBytes = | |
| 32; // 4 * 4 * 2 — double the tight row. | |
| static constexpr uint32_t kPaddedLayerBytes = kPaddedRowBytes * kLayerHeight; | |
| static void fillSolidLayer(uint8_t* dst, uint8_t r, uint8_t g, uint8_t b) | |
| { | |
| memset(dst, 0xCC, kPaddedLayerBytes); // padding sentinel. | |
| for (uint32_t y = 0; y < kLayerHeight; ++y) | |
| { | |
| uint8_t* row = dst + y * kPaddedRowBytes; | |
| for (uint32_t x = 0; x < kLayerWidth; ++x) | |
| { | |
| row[x * 4 + 0] = r; | |
| row[x * 4 + 1] = g; | |
| row[x * 4 + 2] = b; | |
| row[x * 4 + 3] = 0xFF; | |
| } | |
| } | |
| } | |
| // 2×2 RGBA8 tile, non-tightly-packed (8 valid + 8 padding) so partial-region | |
| // uploads still exercise the pitch plumbing. | |
| static constexpr uint32_t kTileWidth = 2; | |
| static constexpr uint32_t kTileHeight = 2; | |
| static constexpr uint32_t kTilePaddedRowBytes = 16; // 2 * 4 valid + 8 padding. | |
| static constexpr uint32_t kTilePaddedBytes = kTilePaddedRowBytes * kTileHeight; | |
| static void fillSolidTile(uint8_t* dst, uint8_t r, uint8_t g, uint8_t b) | |
| { | |
| memset(dst, 0xCC, kTilePaddedBytes); // padding sentinel. | |
| for (uint32_t y = 0; y < kTileHeight; ++y) | |
| { | |
| uint8_t* row = dst + y * kTilePaddedRowBytes; | |
| for (uint32_t x = 0; x < kTileWidth; ++x) | |
| { | |
| row[x * 4 + 0] = r; | |
| row[x * 4 + 1] = g; | |
| row[x * 4 + 2] = b; | |
| row[x * 4 + 3] = 0xFF; | |
| } | |
| } | |
| } | |
| #endif | |
| class OreArrayUploadGM : public GM | |
| { | |
| public: | |
| OreArrayUploadGM() : GM(256, 256) {} | |
| ColorInt clearColor() const override { return 0xff000000; } | |
| void onDraw(rive::Renderer* originalRenderer) override | |
| { | |
| auto renderContext = TestingWindow::Get()->renderContext(); | |
| if (!renderContext || !m_ore.ensureContext(renderContext)) | |
| return; | |
| #ifdef ORE_ARRAY_UPLOAD_ACTIVE | |
| auto& ctx = *renderContext->getOreContext(); | |
| // Array texture: 4×4, 4 layers, 2 mips. The 2-mip count is the | |
| // important bit — it makes the D3D12 transposed-formula bug | |
| // observable. | |
| TextureDesc texDesc{}; | |
| texDesc.width = kLayerWidth; | |
| texDesc.height = kLayerHeight; | |
| texDesc.depthOrArrayLayers = 4; | |
| texDesc.numMipmaps = 2; | |
| texDesc.format = TextureFormat::rgba8unorm; | |
| texDesc.type = TextureType::array2D; | |
| texDesc.label = "ore_array_upload_tex"; | |
| auto arrTex = ctx.makeTexture(texDesc); | |
| if (!arrTex) | |
| return; | |
| // Array2D view covering all 4 layers, both mips. | |
| TextureViewDesc viewDesc{}; | |
| viewDesc.texture = arrTex.get(); | |
| viewDesc.dimension = TextureViewDimension::array2D; | |
| viewDesc.baseMipLevel = 0; | |
| viewDesc.mipCount = 2; | |
| viewDesc.baseLayer = 0; | |
| viewDesc.layerCount = 4; | |
| auto arrView = ctx.makeTextureView(viewDesc); | |
| if (!arrView) | |
| return; | |
| // Sampler clamped to mip 0 — mip 1 is never uploaded so we | |
| // mustn't sample it. | |
| SamplerDesc sampDesc{}; | |
| sampDesc.minFilter = Filter::nearest; | |
| sampDesc.magFilter = Filter::nearest; | |
| sampDesc.mipmapFilter = Filter::nearest; | |
| sampDesc.minLod = 0.0f; | |
| sampDesc.maxLod = 0.0f; | |
| sampDesc.label = "ore_array_upload_sampler"; | |
| auto sampler = ctx.makeSampler(sampDesc); | |
| auto shader = ore_gm::loadShader(ctx, ore_gm::kArray2DWitness); | |
| if (!shader.vsModule) | |
| return; | |
| auto canvas = renderContext->makeRenderCanvas(256, 256); | |
| if (!canvas) | |
| return; | |
| auto canvasView = ctx.wrapCanvasTexture(canvas.get()); | |
| if (!canvasView) | |
| return; | |
| auto layout1 = | |
| ore_gm::makeLayoutFromShader(ctx, shader.vsModule.get(), 1); | |
| auto layout2 = | |
| ore_gm::makeLayoutFromShader(ctx, shader.vsModule.get(), 2); | |
| BindGroupLayout* layouts[] = {nullptr, layout1.get(), layout2.get()}; | |
| PipelineDesc pipeDesc{}; | |
| pipeDesc.vertexModule = shader.vsModule.get(); | |
| pipeDesc.fragmentModule = shader.psModule.get(); | |
| pipeDesc.vertexEntryPoint = shader.vsEntryPoint; | |
| pipeDesc.fragmentEntryPoint = shader.fsEntryPoint; | |
| pipeDesc.vertexBufferCount = 0; | |
| pipeDesc.topology = PrimitiveTopology::triangleList; | |
| pipeDesc.colorTargets[0].format = canvasView->texture()->format(); | |
| pipeDesc.colorCount = 1; | |
| pipeDesc.depthStencil.depthCompare = CompareFunction::always; | |
| pipeDesc.depthStencil.depthWriteEnabled = false; | |
| pipeDesc.bindGroupLayouts = layouts; | |
| pipeDesc.bindGroupLayoutCount = 3; | |
| pipeDesc.label = "ore_array_upload_pipeline"; | |
| auto pipeline = ctx.makePipeline(pipeDesc); | |
| if (!pipeline) | |
| { | |
| fprintf(stderr, | |
| "[ore_array_upload] pipeline creation failed: %s\n", | |
| ctx.lastError().c_str()); | |
| return; | |
| } | |
| BindGroupDesc::TexEntry texEntry{0, arrView.get()}; | |
| BindGroupDesc texBGDesc{}; | |
| texBGDesc.layout = layout1.get(); | |
| texBGDesc.textures = &texEntry; | |
| texBGDesc.textureCount = 1; | |
| auto texBG = ctx.makeBindGroup(texBGDesc); | |
| BindGroupDesc::SampEntry sampEntry{0, sampler.get()}; | |
| BindGroupDesc sampBGDesc{}; | |
| sampBGDesc.layout = layout2.get(); | |
| sampBGDesc.samplers = &sampEntry; | |
| sampBGDesc.samplerCount = 1; | |
| auto sampBG = ctx.makeBindGroup(sampBGDesc); | |
| // Per-layer uploads issued BEFORE m_ore.beginFrame() so the | |
| // Vulkan backend's deferred-upload path is exercised (regression | |
| // for the post-#12661 fix: upload outside an Ore frame must | |
| // queue and drain at the next beginFrame / beginRenderPass). | |
| // Each upload targets `(mipLevel=0, layer=N)` with a non-tightly- | |
| // packed source (bytesPerRow = 32, double the tight row of 16) | |
| // so the GL backend's GL_UNPACK_ROW_LENGTH plumbing is exercised. | |
| uint8_t layerData[kPaddedLayerBytes]; | |
| struct LayerColor | |
| { | |
| uint8_t r, g, b; | |
| }; | |
| const LayerColor kColors[4] = { | |
| {255, 0, 0}, // 0 — red | |
| {0, 255, 0}, // 1 — green | |
| {0, 0, 255}, // 2 — blue | |
| {255, 255, 0}, // 3 — yellow | |
| }; | |
| for (uint32_t layer = 0; layer < 4; ++layer) | |
| { | |
| // Layer 0 as four 2×2 sub-rect tiles. Same solid-red result, but | |
| // exercises every backend's partial-region path. Pre-fix D3D12 | |
| // over-read the whole mip out of each tile buffer. | |
| if (layer == 0) | |
| { | |
| const uint32_t kTileOffsets[4][2] = { | |
| {0, 0}, | |
| {kTileWidth, 0}, | |
| {0, kTileHeight}, | |
| {kTileWidth, kTileHeight}, | |
| }; | |
| uint8_t tileData[kTilePaddedBytes]; | |
| for (const auto& off : kTileOffsets) | |
| { | |
| fillSolidTile(tileData, | |
| kColors[0].r, | |
| kColors[0].g, | |
| kColors[0].b); | |
| TextureDataDesc upload{}; | |
| upload.data = tileData; | |
| upload.bytesPerRow = kTilePaddedRowBytes; | |
| upload.rowsPerImage = kTileHeight; | |
| upload.mipLevel = 0; | |
| upload.layer = 0; | |
| upload.x = off[0]; | |
| upload.y = off[1]; | |
| upload.width = kTileWidth; | |
| upload.height = kTileHeight; | |
| upload.depth = 1; | |
| arrTex->upload(upload); | |
| } | |
| continue; | |
| } | |
| fillSolidLayer(layerData, | |
| kColors[layer].r, | |
| kColors[layer].g, | |
| kColors[layer].b); | |
| TextureDataDesc upload{}; | |
| upload.data = layerData; | |
| upload.bytesPerRow = kPaddedRowBytes; | |
| upload.rowsPerImage = kLayerHeight; | |
| upload.mipLevel = 0; | |
| upload.layer = layer; | |
| upload.width = kLayerWidth; | |
| upload.height = kLayerHeight; | |
| upload.depth = 1; | |
| arrTex->upload(upload); | |
| } | |
| m_ore.beginFrame(renderContext); | |
| ColorAttachment ca{}; | |
| ca.view = canvasView.get(); | |
| ca.loadOp = LoadOp::clear; | |
| ca.storeOp = StoreOp::store; | |
| ca.clearColor = {0, 0, 0, 1}; | |
| RenderPassDesc rpDesc{}; | |
| rpDesc.colorAttachments[0] = ca; | |
| rpDesc.colorCount = 1; | |
| rpDesc.label = "ore_array_upload_pass"; | |
| auto pass = ctx.beginRenderPass(rpDesc); | |
| pass->setPipeline(pipeline.get()); | |
| pass->setBindGroup(1, texBG.get()); | |
| pass->setBindGroup(2, sampBG.get()); | |
| pass->setViewport(0, 0, 256, 256); | |
| pass->draw(3); // big-triangle fullscreen — covers entire NDC. | |
| pass->finish(); | |
| m_ore.endFrame(renderContext); | |
| ore_gm::invalidateGLStateAfterOre(renderContext); | |
| originalRenderer->save(); | |
| originalRenderer->drawImage(canvas->renderImage(), | |
| {.filter = ImageFilter::nearest}, | |
| BlendMode::srcOver, | |
| 1); | |
| originalRenderer->restore(); | |
| #endif | |
| } | |
| private: | |
| ore_gm::OreGMContext m_ore; | |
| }; | |
| GMREGISTER(ore_array_upload, return new OreArrayUploadGM()) |