Search code examples
macostexturesmetal

Changing a small region of my texture causes the rest of the texture to go black


I have an application that does some calculations on the CPU on multiple threads. Each thread calculates a 64x64 pixel region of an output image. When a thread has completed its calculations, it uploads the calculated pixel data to a MTLTexture via -[MTLTexture replaceRegion:]. Before any of the CPU threads start, I have placed a low-res version of the full result into the texture, and I'd like for the threads on the CPU to overwrite the image data as they calculate it at a higher resolution.

I have this working for the most part, but when the first call to -replaceRegion: happens, it appears to clear the texture to black, and then replaces the 64x64 region as requested.

Here's how I'm creating the texture:

MTLTextureDescriptor*   outputTextureDesc   = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm
                                                                                             width:viewportSize.width
                                                                                            height:viewportSize.height
                                                                                         mipmapped:NO];
outputTextureDesc.usage = MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead | MTLTextureUsageRenderTarget;
_outputTexture = [device newTextureWithDescriptor:outputTextureDesc];

Then, when I want to put the low-res version of the output into the texture, I copy it from another texture like this:

const Vertex2D quadVertices[] =
{
    //Pixel Positions, Texture Coordinates
    { { viewportSize.x / 2.0,  viewportSize.y / -2.0 }, { right, bottom } },
    { { viewportSize.x / -2.0, viewportSize.y / -2.0 }, { left, bottom } },
    { { viewportSize.x / -2.0, viewportSize.y / 2.0 }, { left, top } },

    { { viewportSize.x / 2.0,  viewportSize.y / -2.0 }, { right, bottom } },
    { { viewportSize.x / -2.0, viewportSize.y / 2.0 }, { left, top } },
    { { viewportSize.x / 2.0, viewportSize.y / 2.0 }, { right, top } },
};

MTLRenderPassDescriptor *renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor];
renderPassDescriptor.colorAttachments [ 0 ].texture = _outputTexture;
renderPassDescriptor.colorAttachments [ 0 ].loadAction = MTLLoadActionDontCare;
renderPassDescriptor.colorAttachments [ 0 ].storeAction = MTLStoreActionStore;

if(renderPassDescriptor != nil)
{
    id<MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];
    commandBuffer.label = @"Copy Selection Command Buffer";

    // Create a render command encoder so we can render into something
    id<MTLRenderCommandEncoder> renderEncoder =
        [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
    renderEncoder.label = @"Copy Selection Command Encoder";

    [renderEncoder setViewport:(MTLViewport){0.0, 0.0, viewportSize.x, viewportSize.y, -1.0, 1.0 }];

    [renderEncoder setRenderPipelineState:renderPipelineState];

    [renderEncoder setVertexBytes:quadVertices
                           length:sizeof(quadVertices)
                          atIndex:MBV_VertexIndex];

    [renderEncoder setVertexBytes:&viewportSize
                           length:sizeof(viewportSize)
                          atIndex:MBV_ViewportSize];

    [renderEncoder setFragmentTexture:oldRender
                              atIndex:MBF_Texture];

    // Draw the vertices of our triangles
    [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                      vertexStart:0
                      vertexCount:6];

    [renderEncoder endEncoding];
    [commandBuffer commit];
    [commandBuffer waitUntilCompleted];
}

I also tried changing the renderPassDescriptor.colorAttachment [ 0 ].loadAction to MTLLoadActionLoad instead of MTLLoadActionDontCare, as the docs says:

The existing contents of the texture are preserved.

but it didn't make any difference.

Finally, when a thread finishes, it uploads its 64x64 pixel result by doing this:

MTLRegion currentRegion = { { colStart, rowStart, 0}, { colEnd - colStart, rowEnd - rowStart, 1 } };
[_outputTexture replaceRegion:currentRegion
                  mipmapLevel:0
                    withBytes:_outputBitmap + (rowStart * (int)viewportSize.width) + colStart
                  bytesPerRow:viewportSize.width * sizeof(*_outputBitmap)];

If I use the Metal debugging tools to look at the texture after the initial copy of the low-res data, it contains the correct pixels. But after the first call to -replaceRegion:, everything other than the region replaced is black. Subsequent calls to -replaceRegion: work correctly and do not overwrite the previously written results.

I should mention that this texture is also being displayed as it's updated by an MTKView. I do sometimes see the low-res copy for a frame or two before the high-res tiles start filling in. Any idea why the call to -replaceRegion: would clear the texture? (Or what else might be clearing the texture if it's not the call to -replaceRegion:?)


Solution

  • Assuming this is on macOS and not iOS, the texture's storage mode defaults to MTLStorageModeManaged. That means that you must explicitly synchronize the texture to get the CPU to "see" any modifications made by the GPU. Since your initial low-resolution image is drawn into the texture, that's only on the GPU unless/until you synchronize. Since you're failing to do that, the CPU has uninitialized data. When you use the CPU to replace a region, it's modifying its (uninitialized) copy and then pushing that to the GPU. That replaces the drawn content.

    To synchronize, you should create a blit command encoder (MTLBlitCommandEncoder) and use that to encode a synchronization command using -synchronizeResource: or (if you want to be more selective) -synchronizeTexture:slice:level:.

    Finally, I'm not sure, but I'd be concerned about the thread safety of the various -replaceRegion:... calls. So, you should use a serial dispatch queue or something to serialize those.