如何安全地将渲染与更新模型解耦?

2024-01-13

与一些游戏开发人员交谈时,他们建议基于 OpenGL ES 的高性能游戏引擎不会处理主线程上的所有内容。这使得游戏引擎能够在具有多个 CPU 核心的设备上表现更好。

他们说我可以将更新与渲染分离。所以如果我理解正确的话,游戏引擎运行循环可以像这样工作:

  1. 设置一个 CADisplayLink 来调用render method.

  2. render方法在后台渲染当前的世界模型。

  3. render方法然后调用update主线程上的方法。

因此,当它在后台渲染时,它可以同时更新下一次迭代的世界模型。

对我来说,这一切都感觉很奇怪。有人可以解释或链接到这种并发渲染+模型更新在现实中是如何完成的吗?令我难以置信的是,这如何不会导致问题,因为如果模型更新比渲染或其他方式花费的时间更长怎么办?谁在等待什么以及何时等待。

我试图理解的是如何从高层次的角度从理论上和细节上实现这一点。


在“现实”中,有很多不同的方法。没有“一种真正的方法”。什么适合您实际上取决于lot关于您在问题中没有讨论的因素,但无论如何我都会尝试。我也不确定如何CADisplayLink就是你想要的。我通常认为这对于需要帧同步(即音频和视频口型同步)的事情很有用,这听起来不像您所需要的,但让我们看看几种不同的方法可以做到这一点。我认为你问题的关键在于模型和视图之间是否需要第二个“层”。

背景:单线程(即仅主线程)示例

让我们首先考虑一个普通的单线程应用程序如何工作:

  1. 用户事件进入主线程
  2. 事件处理程序触发对控制器方法的调用。
  3. 控制器方法更新模型状态。
  4. 模型状态的更改会使视图状态无效。 (IE。-setNeedsDisplay)
  5. 当下一帧到来时,窗口服务器将触发从当前模型状态重新渲染视图状态并显示结果

请注意,步骤 1-4 可以在步骤 5 发生之间发生多次,但是,由于这是一个单线程应用程序,当步骤 5 发生时,步骤 1-4 不会发生,并且用户事件正在排队等待步骤 5 完成。假设步骤 1-4“非常快”,这通常会以预期的方式丢帧。

将渲染与主线程解耦

现在,让我们考虑一下您想要将渲染卸载到后台线程的情况。在这种情况下,序列应如下所示:

  1. 用户事件进入主线程
  2. 事件处理程序触发对控制器方法的调用。
  3. 控制器方法更新模型状态。
  4. 模型状态的更改会将异步渲染任务排入队列以供后台执行。
  5. 如果异步渲染任务完成,它将生成的位图放置在视图已知的位置,并调用-setNeedsDisplay在视图上。
  6. 当下一帧到来时,窗口服务器将触发对-drawRect在视图上,现在的实现是从“已知共享位置”获取最近完成的位图并将其复制到视图中。

这里有一些细微差别。让我们首先考虑这样一种情况:您只是尝试将渲染与主线程分离(暂时忽略多核的利用——稍后会详细介绍):

几乎可以肯定,您永远不会希望同时运行多个渲染任务。一旦开始渲染一帧,您可能不想取消/停止渲染它。您可能希望将未来未启动的渲染操作排队到单个插槽队列中,该队列始终包含最后一个排队的未启动渲染操作。这应该为您提供合理的丢帧行为,这样您就不会出现“落后”的渲染帧,您应该直接丢掉帧。

如果存在一个完全渲染但尚未显示的框架,我想你always想要显示该框架。考虑到这一点,您不想打电话-setNeedsDisplay直到位图完成并位于已知位置。

您将需要跨线程同步访问。例如,当您将渲染操作排入队列时,最简单的方法是获取模型状态的只读快照,并将其传递给渲染操作,渲染操作只会从快照中读取。这使您不必与“实时”游戏模型同步(该模型可能会通过控制器方法在主线程上发生变化,以响应未来的用户事件。)另一个同步挑战是将已完成的位图传递到视图和召唤-setNeedsDisplay。最简单的方法可能是让图像成为视图上的一个属性,并分派该属性的设置(带有完整的图像)并调用-setNeedsDisplay转到主线程。

这里有一个小问题:如果用户事件以很高的速率传入,并且您能够在单个显示帧的持续时间内(1/60 秒)渲染多个帧,那么您could最终渲染的位图掉落在地板上。这种方法的优点是始终在显示时向视图提供最新的帧(减少感知延迟),但它的缺点是它会产生渲染从未获得的帧的所有计算成本。显示(即功率)。这里的正确权衡因每种情况而异,并且可能包括更细粒度的调整。

利用多核——本质上的并行渲染

假设您已经按照上面的讨论将渲染与主线程分离,并且您的渲染操作本身本质上是可并行的,那么只需并行化您的一个渲染操作,同时继续以相同的方式与视图交互,您就应该获得多核并行性免费。也许您可以将每个帧划分为 N 个图块,其中 N 是核心数量,然后一旦所有 N 个图块完成渲染,您就可以将它们拼凑在一起并将它们传递到视图,就好像渲染操作是整体的一样。如果您正在使用模型的只读快照,则 N 个切片任务的设置成本应该是最小的(因为它们都可以使用相同的源模型。)

利用多核——固有的串行渲染

如果您的渲染操作本质上是串行的(根据我的经验,大多数情况下),您使用多个核心的选择是在飞行中进行与核心一样多的渲染操作。当一帧完成时,它会向任何已排队或仍在飞行中但在之前的渲染操作发出信号,表明它们可能放弃并取消,然后它将自身设置为由视图显示,就像仅解耦示例中一样。

正如仅解耦情况中提到的,这总是在显示时向视图提供最新的帧,但它会产生渲染从未显示的帧的所有计算(即功耗)成本。

当模型速度缓慢时...

我还没有解决实际上是基于用户事件的模型更新太慢的情况,因为从某种意义上来说,如果是这样的话,在很多方面,你就不再关心渲染了。如果渲染如何跟上model甚至跟不上?此外,假设您找到了一种将渲染和模型计算互锁的方法,渲染总是会抢夺模型计算的周期,根据定义,模型计算总是落后的。换句话说,当某个东西本身无法每秒更新 N 次时,你就不能希望每秒渲染该东西 N 次。

我可以设想一些情况,您可以将连续运行的物理模拟之类的东西卸载到后台线程。这样的系统必须自行管理其实时性能,并且假设它这样做,那么您将面临将该系统的结果与传入的用户事件流同步的挑战。一团糟。

在常见情况下,您really希望事件处理和模型突变是way比实时更快,并且渲染是“困难的部分”。我很难想象一个有意义的情况,其中模型更新是限制因素,但您仍然关心解耦渲染以提高性能。

换句话说:如果您的模型只能以 10Hz 更新,那么以超过 10Hz 的速度更新视图就没有意义。当用户事件的发生速度远快于 10Hz 时,这种情况的主要挑战就出现了。该挑战将是有意义地丢弃、采样或合并传入事件,以保持有意义并提供良好的用户体验。

一些代码

下面是一个基于 Xcode 中的 Cocoa 应用程序模板的简单示例,展示了解耦背景渲染的外观。 (我意识到after编写这个基于 OS X 的示例,该问题被标记为ios,所以我想这是“无论它的价值”)

@class MyModel;

@interface NSAppDelegate : NSObject <NSApplicationDelegate>
@property (assign) IBOutlet NSWindow *window;
@property (nonatomic, readwrite, copy) MyModel* model;
@end

@interface MyModel : NSObject <NSMutableCopying>
@property (nonatomic, readonly, assign) CGPoint lastMouseLocation;
@end

@interface MyMutableModel : MyModel
@property (nonatomic, readwrite, assign) CGPoint lastMouseLocation;
@end

@interface MyBackgroundRenderingView : NSView
@property (nonatomic, readwrite, assign) CGPoint coordinates;
@end

@interface MyViewController : NSViewController
@end

@implementation NSAppDelegate
{
    MyViewController* _vc;
    NSTrackingArea* _trackingArea;
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
    // Insert code here to initialize your application
    self.window.acceptsMouseMovedEvents = YES;

    int opts = (NSTrackingActiveAlways | NSTrackingInVisibleRect | NSTrackingMouseMoved);
    _trackingArea = [[NSTrackingArea alloc] initWithRect: [self.window.contentView bounds]
                                                        options:opts
                                                          owner:self
                                                       userInfo:nil];
    [self.window.contentView addTrackingArea: _trackingArea];


    _vc = [[MyViewController alloc] initWithNibName: NSStringFromClass([MyViewController class]) bundle: [NSBundle mainBundle]];
    _vc.representedObject = self;

    _vc.view.frame = [self.window.contentView bounds];
    [self.window.contentView addSubview: _vc.view];
}

- (void)mouseEntered:(NSEvent *)theEvent
{
}

- (void)mouseExited:(NSEvent *)theEvent
{
}

- (void)mouseMoved:(NSEvent *)theEvent
{
    // Update the model for mouse movement.
    MyMutableModel* mutableModel = self.model.mutableCopy ?: [[MyMutableModel alloc] init];
    mutableModel.lastMouseLocation = theEvent.locationInWindow;
    self.model = mutableModel;
}

@end

@interface MyModel ()
// Re-declare privately so the setter exists for the mutable subclass to use
@property (nonatomic, readwrite, assign) CGPoint lastMouseLocation;
@end

@implementation MyModel

@synthesize lastMouseLocation;

- (id)copyWithZone:(NSZone *)zone
{
    if ([self isMemberOfClass: [MyModel class]])
    {
        return self;
    }

    MyModel* copy = [[MyModel alloc] init];
    copy.lastMouseLocation = self.lastMouseLocation;
    return copy;
}

- (id)mutableCopyWithZone:(NSZone *)zone
{
    MyMutableModel* copy = [[MyMutableModel alloc] init];
    copy.lastMouseLocation = self.lastMouseLocation;
    return copy;
}

@end

@implementation MyMutableModel
@end

@interface MyViewController (Downcast)
- (MyBackgroundRenderingView*)view; // downcast
@end

@implementation MyViewController

static void * const MyViewControllerKVOContext = (void*)&MyViewControllerKVOContext;

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
    if (self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil])
    {
        [self addObserver: self forKeyPath: @"representedObject.model.lastMouseLocation" options: NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context: MyViewControllerKVOContext];
    }
    return self;
}

- (void)dealloc
{
    [self removeObserver: self forKeyPath: @"representedObject.model.lastMouseLocation" context: MyViewControllerKVOContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if (MyViewControllerKVOContext == context)
    {
        // update the view...
        NSValue* oldCoordinates = change[NSKeyValueChangeOldKey];
        oldCoordinates = [oldCoordinates isKindOfClass: [NSValue class]] ? oldCoordinates : nil;
        NSValue* newCoordinates = change[NSKeyValueChangeNewKey];
        newCoordinates = [newCoordinates isKindOfClass: [NSValue class]] ? newCoordinates : nil;
        CGPoint old = CGPointZero, new = CGPointZero;
        [oldCoordinates getValue: &old];
        [newCoordinates getValue: &new];

        if (!CGPointEqualToPoint(old, new))
        {
            self.view.coordinates = new;
        }
    }
    else
    {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

@end

@interface MyBackgroundRenderingView ()
@property (nonatomic, readwrite, retain) id toDisplay; // doesn't need to be atomic because it should only ever be used on the main thread.
@end

@implementation MyBackgroundRenderingView
{
    // Pointer sized reads/
    intptr_t _lastFrameStarted;
    intptr_t _lastFrameDisplayed;
    CGPoint _coordinates;
}

@synthesize coordinates = _coordinates;

- (void)setCoordinates:(CGPoint)coordinates
{
    _coordinates = coordinates;

    // instead of setNeedDisplay...
    [self doBackgroundRenderingForPoint: coordinates];
}

- (void)setNeedsDisplay:(BOOL)flag
{
    if (flag)
    {
        [self doBackgroundRenderingForPoint: self.coordinates];
    }
}

- (void)doBackgroundRenderingForPoint: (CGPoint)value
{
    NSAssert(NSThread.isMainThread, @"main thread only...");

    const intptr_t thisFrame = _lastFrameStarted++;
    const NSSize imageSize = self.bounds.size;
    const NSRect imageRect = NSMakeRect(0, 0, imageSize.width, imageSize.height);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

        // If another frame is already queued up, don't bother starting this one
        if (_lastFrameStarted - 1 > thisFrame)
        {
            dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSLog(@"Not rendering a frame because there's a more recent one queued up already."); });
            return;
        }

        // introduce an arbitrary fake delay between 1ms and 1/15th of a second)
        const uint32_t delays = arc4random_uniform(65);
        for (NSUInteger i = 1; i < delays; i++)
        {
            // A later frame has been displayed. Give up on rendering this old frame.
            if (_lastFrameDisplayed > thisFrame)
            {
                dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSLog(@"Aborting rendering a frame that wasn't ready in time"); });
                return;
            }
            usleep(1000);
        }

        // render image...
        NSImage* image = [[NSImage alloc] initWithSize: imageSize];
        [image lockFocus];
        NSString* coordsString = [NSString stringWithFormat: @"%g,%g", value.x, value.y];
        [coordsString drawInRect: imageRect withAttributes: nil];
        [image unlockFocus];

        NSArray* toDisplay = @[ image, @(thisFrame) ];
        dispatch_async(dispatch_get_main_queue(), ^{
            self.toDisplay = toDisplay;
            [super setNeedsDisplay: YES];
        });
    });
}

- (void)drawRect:(NSRect)dirtyRect
{
    NSArray* toDisplay = self.toDisplay;
    if (!toDisplay)
        return;
    NSImage* img = toDisplay[0];
    const int64_t frameOrdinal = [toDisplay[1] longLongValue];

    if (frameOrdinal < _lastFrameDisplayed)
        return;

    [img drawInRect: self.bounds];
    _lastFrameDisplayed = frameOrdinal;

    dispatch_async(dispatch_get_global_queue(0, 0), ^{ NSLog(@"Displayed a frame"); });
}

@end

结论

抽象地说,只需将渲染与主线程解耦,但不一定并行化(即第一种情况)可能就足够了。为了更进一步,您可能想要研究并行化每帧渲染操作的方法。并行绘制多个帧具有一些优势,但在像 iOS 这样的电池供电环境中,它可能会将您的应用程序/游戏变成耗电大户。

对于模型更新(而不是渲染)成为限制因素的任何情况,正确的方法将在很大程度上取决于情况的具体细节,并且与渲染相比,更难概括。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

如何安全地将渲染与更新模型解耦? 的相关文章

随机推荐