【问题标题】:How to safely decouple rendering from updating the model?如何安全地将渲染与更新模型解耦?
【发布时间】:2013-12-18 01:43:16
【问题描述】:

在与一些游戏开发者交谈时,他们建议基于高性能 OpenGL ES 的游戏引擎无法处理主线程上的所有内容。这允许游戏引擎在具有多个 CPU 内核的设备上表现更好。

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

  1. 设置调用render 方法的CADisplayLink。

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

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

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

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

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

【问题讨论】:

    标签: ios concurrency grand-central-dispatch game-engine game-loop


    【解决方案1】:

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

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

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

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

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

    从主线程中解耦渲染

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

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

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

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

    如果存在完全渲染但尚未显示的帧,我认为您总是想要显示该帧。考虑到这一点,在位图完成并位于已知位置之前,您不希望在视图上调用 -setNeedsDisplay

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

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

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

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

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

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

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

    当模型慢时...

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

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

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

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

    一些代码

    这是一个基于 Xcode 中的 Cocoa 应用程序模板的解耦背景渲染的简单示例。 (我意识到对这个基于 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 这样的电池供电环境中,它可能会将您的应用程序/游戏变成耗电大户。

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

    【讨论】:

    • 感谢 ipmcc 的详细回答。如果目标是将渲染与主线程分离,那么实际问题是:如何安全地同步它,以便当触摸事件导致基于objective-c对象的模型树(许多对象和属性)中的更新时,渲染线程不会在您写入模型时尝试读取模型?我发现,当您将保留根模型对象(“场景”)的属性设为“原子”类型时,仍然可以同时访问树中的任何其他属性。
    • 解决方案是否可以将修改模型的任何事件排入串行 GCD 后台队列中,然后该队列也用于渲染到 OpenGL ES 1.1?感觉要么一切都发生在主线程上,要么在后台发生,因为在写入模型时,我找不到一种方法来安全地保护模型免受渲染线程的影响。
    • 我在这里推荐的是使您的模型不可变或不共享。不可变的方法更简洁(因为一般来说,任何不可变的数据对于来自多个线程的并发读取都是安全的),但更麻烦。 (即,您最终会为所有内容维护可变子类等)。 “非共享”方法有时更容易——使用这种方法,您可以制作整个模型的“深度复制”(在 Cocoa 中,这通常可以使用NSCoding 轻松实现),以便在每次渲染操作时进行移交。这样渲染操作就不必关心后续更新了。
    • 我几乎毫不犹豫地推荐这个,因为它假定了 很多 的先验知识,但是对于一个 非常 密集的、以 C++ 为中心的讨论价值语义(即不可变)模型的好处 这是一个非常有趣的谈话:channel9.msdn.com/Events/GoingNative/2013/… 但当我说它非常密集且非常以 C++ 为中心时,我真的不是在开玩笑。挑战在于吸取教训并将它们带回您正在使用的习语。
    【解决方案2】:

    我的 2 美分值。

    在我有限的理解中,GL 游戏总是先更新然后渲染。

    更新周期基本上将所有游戏中视觉变化的部分(即:位置/颜色/等)更新为下一个时间值。这可以通过工作线程完成,在您的情况下,可能提前排队到一组未来 t,t+1,t+2,t+n 未来值中。

    渲染周期使用上面的计算值选择性地(t,t+1,t+2,t+n)在主线程内进行实际渲染。所有渲染都必须在主线程内完成,否则您将开始看到奇怪的工件。在渲染周期中,根据重叠的时间值,可以跳帧/快进(即:渲染t+1,t+4值)或慢动作播放(t+0.1,t+0.2)。

    祝你学业顺利!

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2010-11-27
      • 1970-01-01
      • 1970-01-01
      • 2011-09-05
      • 2011-09-19
      • 2018-03-07
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多