深入了解 metal 4 的最新改进。我们将介绍全新光线追踪功能,助你将复杂度极高且视觉效果丰富的工作负载成功移植到 apple 芯片上。了解 metalfx 如何通过渲染画质提升、帧插值和场景去噪来扩展工作负载。

为了充分从这个讲座中获益,我们建议你先观看“探索 metal 4”和“探索 metal 4 游戏”。

视频封面
视频地址

此文章由AI生成,可能存在错误,如有问题,请联系djs66256@163.com

深入探索 Metal 4 游戏

Metal 4 的推出为 Apple 平台上的游戏开发者带来了全新的可能性,特别是在光线追踪、渲染性能和视觉效果提升方面。本次演讲由 Matias Koskela 主讲,深入探讨了如何利用 MetalFX 和 Metal 4 的新特性来优化游戏性能,并将复杂的工作负载成功移植到 Apple 芯片上。以下是对演讲内容的全面解析。

简介

随着游戏画质的不断提升,实时渲染的复杂性和计算成本也在显著增加。例如《赛博朋克2077》这样的现代游戏,通过实时路径追踪技术实现了令人惊叹的画面效果,但这也对硬件性能提出了更高的要求。Metal 提供了一系列工具和 API,帮助开发者在 iPhone 到 Mac 的所有 Apple 平台上实现高质量的图形渲染。MetalFX Upscaling 和帧插值等技术可以有效应对高分辨率和高帧率带来的挑战,而新的光线追踪功能则进一步提升了渲染的真实感。

为了更好地理解这些新技术,建议先观看“探索 Metal 4”和“探索 Metal 4 游戏”,以获得关于 Metal 4 基础知识及其游戏应用的背景信息。

放大渲染

放大渲染是一种广泛使用的技术,它可以帮助开发者在不牺牲质量的前提下提高渲染性能。MetalFX 提供了一个基于机器学习的放大器,自 2022 年起便成为 Apple 平台的一部分,并逐年改进。以下是实现高质量放大的关键技术要点:

曝光参数设置

正确设置曝光输入参数对于放大器的输出质量至关重要。如果传递的曝光值与色调映射器不匹配,可能会导致闪烁或鬼影现象。为此,MetalFX 提供了一个名为“曝光调试器”的工具,可以通过设置环境变量 MTLFX_EXPOSURE_TOOL_ENABLED 来启用。该工具会在帧上叠加一个灰色棋盘格图案,并根据曝光值调整其亮度。如果曝光值设置正确,棋盘格应保持恒定的中灰色。

// 在主机代码中启用曝光调试器
setenv("MTLFX_EXPOSURE_TOOL_ENABLED", "1", 1);

动态分辨率支持

许多游戏采用动态分辨率渲染以适应不同场景的复杂度。MetalFX 时间放大器现在支持动态大小的输入,允许每帧传递不同分辨率的纹理。在高缩放比率下,建议将最大缩放比例限制在 2 倍以内,以确保缩放质量。

反应掩码

当渲染透明效果或粒子时(如烟花),这些元素通常不会被写入运动和深度纹理中,从而可能导致伪影或鬼影问题。MetalFX 引入了反应掩码功能,用于标记这些效果覆盖的区域。

// 在着色器中创建反应掩码设置
out.reactivity = m_material_id == eRain ? (m_material_id == eSpark ? 1.0f : 0.0f) : 0.8f;

// 在主机上编码放大器之前设置反应掩码
temporalUpscaler.reactiveMask = reactiveMaskTexture;

通过合理使用反应掩码,可以显著改善透明效果的渲染质量。

插值帧

帧插值是提高刷新率的一种有效方法,特别适用于需要流畅体验的游戏。MetalFX 帧插值器能够通过已渲染的像素生成插值帧,从而实现更高的帧率。

集成帧插值器

要使用 MetalFX 帧插值器,首先需要设置插值器对象并绑定必要的纹理输入,包括颜色、深度和运动矢量纹理。

// 创建并配置插值器描述符
MTLFXFrameInterpolatorDescriptor* desc = [MTLFXFrameInterpolatorDescriptor new];
desc.scaler = temporalScaler;

// 创建插值器并设置参数
id<MTLFXFrameInterpolator> interpolator = [desc newFrameInterpolatorWithDevice:device];
interpolator.motionVectorScaleX = mvecScaleX;
interpolator.motionVectorScaleY = mvecScaleY;
interpolator.depthReversed = YES;

// 设置输入纹理
interpolator.colorTexture = colorTexture;
interpolator.prevColorTexture = prevColorTexture;
interpolator.depthTexture = depthTexture;
interpolator.motionTexture = motionTexture;
interpolator.outputTexture = outputTexture;

UI 渲染策略

在插值帧中渲染 UI 是一个关键步骤。常见的三种方法包括合成 UI、离屏 UI 和每帧 UI。每种方法都有其优缺点,开发者可以根据需求选择最适合的方案。

  • 合成 UI:插值器会尝试移除原始帧中的 UI 并将其放置在插值帧中的正确位置。
  • 离屏 UI:将 UI 渲染到独立的纹理中,然后由插值器进行合成。
  • 每帧 UI:完全由开发者负责更新插值帧的 UI,可提供最流畅的体验。

帧节奏协调

插值帧的呈现顺序和间隔对游戏的整体流畅性至关重要。通过使用 Metal HUD 工具,开发者可以轻松识别帧节奏是否偏离目标刷新率。

#include <thread>
#include <mutex>
#include <sys/event.h>
#include <mach/mach_time.h>

class PresentThread
{
    int m_timerQueue;
    std::thread m_encodingThread, m_pacingThread;
    std::mutex m_mutex;
    std::condition_variable m_scheduleCV, m_threadCV, m_pacingCV;
    float m_minDuration;
    
    uint32_t m_width, m_height;
    MTLPixelFormat m_pixelFormat;
    
    const static uint32_t kNumBuffers = 3;
    uint32_t m_bufferIndex, m_inputIndex;
    bool m_renderingUI, m_presentsPending;
    
    CAMetalLayer *m_metalLayer;
    id<MTLCommandQueue> m_presentQueue;

    id<MTLEvent> m_event;
    id<MTLSharedEvent> m_paceEvent, m_paceEvent2;
    uint64_t m_eventValue;
    uint32_t m_paceCount;
    
    int32_t m_numQueued, m_framesInFlight;
    
    id<MTLTexture> m_backBuffers[kNumBuffers];
    id<MTLTexture> m_interpolationOutputs[kNumBuffers];
    id<MTLTexture> m_interpolationInputs[2];
    id<MTLRenderPipelineState> m_copyPipeline;
    
    std::function<void(id<MTLRenderCommandEncoder>)> m_uiCallback = nullptr;
    
    void PresentThreadFunction();
    void PacingThreadFunction();
    
    void CopyTexture(id<MTLCommandBuffer> commandBuffer, id<MTLTexture> dest, id<MTLTexture> src, NSString *label);

public:
    
    PresentThread(float minDuration, CAMetalLayer *metalLayer);
    ~PresentThread()
    {
        std::unique_lock<std::mutex> lock(m_mutex);
        m_numQueued = -1;
        m_threadCV.notify_one();
        m_encodingThread.join();
    }
    void StartFrame(id<MTLCommandBuffer> commandBuffer)
    {
        [commandBuffer encodeWaitForEvent:m_event value:m_eventValue++];
    }

    void StartUI(id<MTLCommandBuffer> commandBuffer)
    {
        assert(m_uiCallback == nullptr);
        if(!m_renderingUI)
        {
            CopyTexture(commandBuffer, m_interpolationInputs[m_inputIndex], m_backBuffers[m_bufferIndex], @"Copy HUDLESS");
            m_renderingUI = true;
        }
    }
    
    void Present(id<MTLFXFrameInterpolator> frameInterpolator, id<MTLCommandQueue> queue);
    
    id<MTLTexture> GetBackBuffer()
    {
        return m_backBuffers[m_bufferIndex];
    }

    void Resize(uint32_t width, uint32_t height, MTLPixelFormat pixelFormat);
    
    void DrainPendingPresents()
    {
        std::unique_lock<std::mutex> lock(m_mutex);
        while(m_presentsPending)
            m_scheduleCV.wait(lock);
    }
    
    bool UICallbackEnabled() const
    {
        return m_uiCallback != nullptr;
    }
    
    void SetUICallback(std::function<void(id<MTLRenderCommandEncoder>)> callback)
    {
        m_uiCallback = callback;
    }
    
};

PresentThread::PresentThread(float minDuration, CAMetalLayer *metalLayer)
    : m_encodingThread(&PresentThread::PresentThreadFunction, this)
    , m_pacingThread(&PresentThread::PacingThreadFunction, this)
    , m_minDuration(minDuration)
    , m_numQueued(0)
    , m_metalLayer(metalLayer)
    , m_inputIndex(0u)
    , m_bufferIndex(0u)
    , m_renderingUI(false)
    , m_presentsPending(false)
    , m_framesInFlight(0)
    , m_paceCount(0)
    , m_eventValue(0)
{
    id<MTLDevice> device = metalLayer.device;
    m_presentQueue = [device newCommandQueue];
    m_presentQueue.label = @"presentQ";
    m_timerQueue = kqueue();
    
    metalLayer.maximumDrawableCount = 3;
    
    Resize(metalLayer.drawableSize.width, metalLayer.drawableSize.height, metalLayer.pixelFormat);
    
    m_event = [device newEvent];
    m_paceEvent = [device newSharedEvent];
    m_paceEvent2 = [device newSharedEvent];
}


void PresentThread::Present(id<MTLFXFrameInterpolator> frameInterpolator, id<MTLCommandQueue> queue)
{
    id<MTLCommandBuffer> commandBuffer = [queue commandBuffer];
    
    if(m_renderingUI)
    {
        frameInterpolator.colorTexture = m_interpolationInputs[m_inputIndex];
        frameInterpolator.prevColorTexture = m_interpolationInputs[m_inputIndex^1];
        frameInterpolator.uiTexture = m_backBuffers[m_bufferIndex];
    }
    else
    {
        frameInterpolator.colorTexture = m_backBuffers[m_bufferIndex];
        frameInterpolator.prevColorTexture = m_backBuffers[(m_bufferIndex + kNumBuffers - 1) % kNumBuffers];
        frameInterpolator.uiTexture = nullptr;
    }
    
    frameInterpolator.outputTexture = m_interpolationOutputs[m_bufferIndex];

    [frameInterpolator encodeToCommandBuffer:commandBuffer];
    [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> _Nonnull) {
        std::unique_lock<std::mutex> lock(m_mutex);
        m_framesInFlight--;
        m_scheduleCV.notify_one();
        m_paceCount++;
        m_pacingCV.notify_one();
    }];
    [commandBuffer encodeSignalEvent:m_event value:m_eventValue++];
    [commandBuffer commit];

    std::unique_lock<std::mutex> lock(m_mutex);
    m_framesInFlight++;
    m_numQueued++;
    m_presentsPending = true;
    m_threadCV.notify_one();
    while((m_framesInFlight >= 2) || (m_numQueued >= 2))
        m_scheduleCV.wait(lock);

    m_bufferIndex = (m_bufferIndex + 1) % kNumBuffers;
    m_inputIndex = m_inputIndex^1u;
    m_renderingUI = false;
}

void PresentThread::CopyTexture(id<MTLCommandBuffer> commandBuffer, id<MTLTexture> dest, id<MTLTexture> src, NSString *label)
{
    MTLRenderPassDescriptor *desc = [MTLRenderPassDescriptor new];
    desc.colorAttachments[0].texture = dest;
    desc.colorAttachments[0].loadAction = MTLLoadActionDontCare;
    desc.colorAttachments[0].storeAction = MTLStoreActionStore;
    id<MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:desc];
    [renderEncoder setFragmentTexture:src atIndex:0];
    [renderEncoder setRenderPipelineState:m_copyPipeline];
    [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];
    if(m_uiCallback)
        m_uiCallback(renderEncoder);
    renderEncoder.label = label;
    [renderEncoder endEncoding];
}


void PresentThread::PacingThreadFunction()
{
    NSThread *thread = [NSThread currentThread];
    [thread setName:@"PacingThread"];
    [thread setQualityOfService:NSQualityOfServiceUserInteractive];
    [thread setThreadPriority:1.f];
    
    mach_timebase_info_data_t info;
    mach_timebase_info(&info);
    
    // maximum delta (0.1ms) in machtime units
    const uint64_t maxDeltaInNanoSecs = 100000000;
    const uint64_t maxDelta = maxDeltaInNanoSecs * info.denom / info.numer;
    
    uint64_t time = mach_absolute_time();
    
    uint64_t paceEventValue = 0;
    
    for(;;)
    {
        std::unique_lock<std::mutex> lock(m_mutex);
        while(m_paceCount == 0)
            m_pacingCV.wait(lock);
        m_paceCount--;
        lock.unlock();
        
        // we get signal...
        const uint64_t prevTime = time;
        time = mach_absolute_time();
        m_paceEvent.signaledValue = ++paceEventValue;

        const uint64_t delta = std::min(time - prevTime, maxDelta);
        const uint64_t timeStamp = time + ((delta*31)>>6);
        
        struct kevent64_s timerEvent, eventOut;
        struct timespec timeout;
        timeout.tv_nsec = maxDeltaInNanoSecs;
        timeout.tv_sec = 0;
        EV_SET64(&timerEvent,
                 0,
                 EVFILT_TIMER,
                 EV_ADD | EV_ONESHOT | EV_ENABLE,
                 NOTE_CRITICAL | NOTE_LEEWAY | NOTE_MACHTIME | NOTE_ABSOLUTE,
                 timeStamp,
                 0,
                 0,
                 0);
        
        kevent64(m_timerQueue, &timerEvent, 1, &eventOut, 1, 0, &timeout);
        
        // main screen turn on...
        m_paceEvent2.signaledValue = ++paceEventValue;
    }
}


void PresentThread::PresentThreadFunction()
{
    NSThread *thread = [NSThread currentThread];
    [thread setName:@"PresentThread"];
    [thread setQualityOfService:NSQualityOfServiceUserInteractive];
    [thread setThreadPriority:1.f];
    

    uint64_t eventValue = 0;
    uint32_t bufferIndex = 0;

    uint64_t paceEventValue = 0;

    for(;;)
    {
        std::unique_lock<std::mutex> lock(m_mutex);
        
        if(m_numQueued == 0)
        {
            m_presentsPending = false;
            m_scheduleCV.notify_one();
        }
        
        while(m_numQueued == 0)
            m_threadCV.wait(lock);
        
        if(m_numQueued < 0)
            break;
        lock.unlock();

        @autoreleasepool
        {
            id<CAMetalDrawable> drawable = [m_metalLayer nextDrawable];

            lock.lock();
            m_numQueued--;
            m_scheduleCV.notify_one();
            lock.unlock();

            id<MTLCommandBuffer> commandBuffer = [m_presentQueue commandBuffer];
            [commandBuffer encodeWaitForEvent:m_event value:++eventValue];
            CopyTexture(commandBuffer, drawable.texture, m_interpolationOutputs[bufferIndex], @"Copy Interpolated");
            [commandBuffer encodeSignalEvent:m_event value:++eventValue];
            [commandBuffer encodeWaitForEvent:m_paceEvent value:++paceEventValue];

            if(m_minDuration > 0.f)
                [commandBuffer presentDrawable:drawable afterMinimumDuration:m_minDuration];
            else
                [commandBuffer presentDrawable:drawable];
            [commandBuffer commit];
        }
        
        @autoreleasepool
        {
            id<MTLCommandBuffer> commandBuffer = [m_presentQueue commandBuffer];
            id<CAMetalDrawable> drawable = [m_metalLayer nextDrawable];
            CopyTexture(commandBuffer, drawable.texture, m_backBuffers[bufferIndex], @"Copy Rendered");
            [commandBuffer encodeWaitForEvent:m_paceEvent2 value:++paceEventValue];
            if(m_minDuration > 0.f)
                [commandBuffer presentDrawable:drawable afterMinimumDuration:m_minDuration];
            else
                [commandBuffer presentDrawable:drawable];
            [commandBuffer commit];
        }
        
        bufferIndex = (bufferIndex + 1) % kNumBuffers;
    }
}

void PresentThread::Resize(uint32_t width, uint32_t height, MTLPixelFormat pixelFormat)
{
    if((m_width != width) || (m_height != height) || (m_pixelFormat != pixelFormat))
    {
        id<MTLDevice> device = m_metalLayer.device;

        if(m_pixelFormat != pixelFormat)
        {
            id<MTLLibrary> lib = [device newDefaultLibrary];
            MTLRenderPipelineDescriptor *pipelineDesc = [MTLRenderPipelineDescriptor new];
            pipelineDesc.vertexFunction = [lib newFunctionWithName:@"FSQ_VS_V4T2"];
            pipelineDesc.fragmentFunction = [lib newFunctionWithName:@"FSQ_simpleCopy"];
            pipelineDesc.colorAttachments[0].pixelFormat = pixelFormat;
            m_copyPipeline = [device newRenderPipelineStateWithDescriptor:pipelineDesc error:nil];
            m_pixelFormat = pixelFormat;
        }
        
        DrainPendingPresents();
        
        m_width = width;
        m_height = height;
        
        MTLTextureDescriptor *texDesc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:pixelFormat width:width height:height mipmapped:NO];
        texDesc.storageMode = MTLStorageModePrivate;
        for(uint32_t i = 0; i < kNumBuffers; i++)
        {
            texDesc.usage = MTLTextureUsageShaderRead|MTLTextureUsageShaderWrite|MTLTextureUsageRenderTarget;
            m_backBuffers[i] = [device newTextureWithDescriptor:texDesc];
            texDesc.usage = MTLTextureUsageShaderRead|MTLTextureUsageRenderTarget;
            m_interpolationOutputs[i] = [device newTextureWithDescriptor:texDesc];
        }
        texDesc.usage = MTLTextureUsageShaderRead|MTLTextureUsageRenderTarget;
        m_interpolationInputs[0] = [device newTextureWithDescriptor:texDesc];
        m_interpolationInputs[1] = [device newTextureWithDescriptor:texDesc];

    }
};

正确的帧节奏表现为平坦的线或规律的重复模式,具体取决于目标刷新率。

使用 Metal 4 追踪光线

光线追踪技术在高端渲染场景中越来越普及,Metal 4 提供了多项新功能来简化光线追踪的实现和优化。

交集函数缓冲区

交集函数缓冲区是一个包含指向场景交集函数句柄的参数缓冲区,允许开发者轻松管理多个交集函数。通过在实例和几何级别设置交集函数表偏移,可以灵活地定义每个几何体的交集行为。

// 在主机端几何描述符上设置交集函数表偏移
NSMutableArray<MTLAccelerationStructureGeometryDescriptor *> *geomDescs ...;
for (auto g = 0; g < geomList.size(); ++g)
{
    MTLAccelerationStructureGeometryDescriptor *descriptor = ...;
    descriptor.intersectionFunctionTableOffset = g;
    ...
    [geomDescs addObject:descriptor];
}

加速结构构建优化

Metal 4 提供了更多控制加速结构构建的选项,包括快速交集和最小化内存使用。这些选项可以通过标志按需设置,以满足不同场景的需求。

// 光线追踪交集函数缓冲区
intersection_function_buffer_arguments ifb_arguments;
ifb_arguments.intersection_function_buffer = raytracingResources.ifbBuffer;
ifb_arguments.intersection_function_buffer_size = raytracingResources.ifbBufferSize;
ifb_arguments.intersection_function_stride = raytracingResources.ifbBufferStride;

metal::raytracing::ray r = { origin, direction };
auto result = trace.intersect(r, ads, ifb_arguments);

在放大时去噪

实时光线追踪的应用范围不断扩大,从混合光线追踪到复杂的路径追踪都得到了广泛应用。然而,光线追踪的性能和质量权衡始终是一个挑战。MetalFX 去噪放大器结合了去噪和放大功能,能够显著简化渲染管线的实现。

去噪放大器集成

去噪放大器的集成非常简单,只需在典型的时间放大器基础上添加额外的输入纹理,包括法线、漫反射反照率、粗糙度和镜面反照率。

// 设置去噪放大器的输入纹理
denoisingUpscaler.normalTexture = normalTexture;
denoisingUpscaler.albedoTexture = albedoTexture;
denoisingUpscaler.roughnessTexture = roughnessTexture;
denoisingUpscaler.specularTexture = specularTexture;

提高质量的技巧

去噪放大器的效果可以通过一些可选输入进一步提升,例如镜面命中距离、去噪强度掩码和透明度叠加。同时,需要注意避免输入过于嘈杂的问题,建议采用先进的采样技术,如重要性采样和下一次事件估计。

下一步

Metal 4 和 MetalFX 的新功能为开发者提供了强大的工具,用以优化游戏性能和提升视觉效果。无论是通过帧插值实现更高的刷新率,还是通过去噪放大器减少光线预算,这些技术都能显著改善玩家的体验。如果你尚未开始使用 MetalFX 放大器,这是一个值得尝试的机会。

相关视频
探索 Metal 4
探索 Metal 4 游戏
用于打造沉浸式 App 的 Metal 渲染的新功能
Metal 光线追踪指南
利用 MetalFX Upscaling 提升性能