[{"data":1,"prerenderedAt":842},["ShallowReactive",2],{"blog-\u002Fblog\u002Fgamemaker-culling-stable-fps":3,"blog-surround-\u002Fblog\u002Fgamemaker-culling-stable-fps":837},{"id":4,"title":5,"author":6,"body":7,"cover":44,"date":822,"description":823,"draft":824,"extension":825,"game":826,"meta":827,"navigation":218,"path":828,"seo":829,"stem":830,"tags":831,"__hash__":836},"blog\u002Fblog\u002Fgamemaker-culling-stable-fps.md","GameMaker Culling and Optimization for Stable FPS","Iron Lizard Games",{"type":8,"value":9,"toc":802},"minimark",[10,14,22,27,35,38,49,53,60,63,125,131,147,150,176,180,183,190,324,327,332,342,353,357,364,375,378,382,385,394,398,404,411,420,423,437,440,444,447,450,526,529,540,544,547,551,554,561,576,580,583,594,601,605,612,615,619,625,632,635,660,664,667,690,704,722,728,734,738,746,760,764,798],[11,12,13],"p",{},"A Mokgard battle map is a lot of small things: puddles, sea foam, river rapids, burning cells, lamps, torches, trees with shadows. Most of them are off screen at any given moment, and all of them would happily run their Step and Draw events every frame if we let them.",[11,15,16,17,21],{},"This post covers the ",[18,19,20],"strong",{},"GameMaker culling"," setup we use to keep battles at a stable frame rate: a timed cull pass built on instance deactivation, effects that cull themselves, caching work the frame doesn't need, and GPU costs that culling doesn't touch. It also covers the fixes that looked like they worked and didn't.",[23,24,26],"h2",{"id":25},"why-mokgard-needed-a-culling-system","Why Mokgard needed a culling system",[11,28,29,34],{},[30,31,33],"a",{"href":32},"\u002Fgame\u002Fmokgard","Mokgard: The War of the Oath"," is a turn-based tactics game in pixel art, written in GameMaker (GML). Battles happen on maps larger than the screen, with light, weather and day\u002Fnight that change the battlefield. A night map can have torches, bonfires, lamps with dynamic lights, rain puddles and fire spreading across cells, all at once.",[11,36,37],{},"None of these objects is expensive on its own. The problem is the count. A shader on a puddle, a light pass for a torch, a particle emitter on a burning cell: each is cheap until there are dozens of them, and most of them are drawing for nobody.",[11,39,40,45],{},[41,42],"img",{"alt":43,"src":44},"A dusk battle in a Mokgard fishing village: units on a cobbled square, houses, palm trees, a river and the sea on the right, with the turn panel visible","\u002Fimg\u002Fmokgard-war-of-the-oath\u002Fbattle_03.webp",[46,47,48],"em",{},"A typical Mokgard battlefield. Water effects, lights and props extend well past the camera in every direction.",[23,50,52],{"id":51},"measure-before-you-cull-fps-fps_real-and-the-perf-panel","Measure before you cull: fps, fps_real and the Perf panel",[11,54,55,56,59],{},"Before touching any code, we added a ",[18,57,58],{},"Perf"," panel to our in-game debug system. In GameMaker it's easy to guess wrong about performance, because the FPS number on screen hides most of what matters.",[11,61,62],{},"GameMaker gives you three different numbers, and they answer different questions:",[64,65,66,79],"table",{},[67,68,69],"thead",{},[70,71,72,76],"tr",{},[73,74,75],"th",{},"Value",[73,77,78],{},"What it tells you",[80,81,82,94,108],"tbody",{},[70,83,84,91],{},[85,86,87],"td",{},[88,89,90],"code",{},"fps",[85,92,93],{},"Frames actually shown per second, capped at the game speed (60 for us).",[70,95,96,101],{},[85,97,98],{},[88,99,100],{},"fps_real",[85,102,103,104,107],{},"How many steps per second the CPU ",[46,105,106],{},"could"," run. Well above 60 means headroom.",[70,109,110,116],{},[85,111,112,115],{},[88,113,114],{},"fps_average"," (debug overlay)",[85,117,118,119,121,122,124],{},"An average of ",[88,120,100],{},", not of ",[88,123,90],{},". We confused the two until we showed all three side by side.",[11,126,127,128,130],{},"Our rule of thumb is simple. If ",[88,129,100],{}," is far above 60, the game has room to spare. If it drops close to 60, look at which counter went up. The panel lists the usual suspects: instance count, dynamic lights on versus total, puddles, live cell effects and burning cells. It refreshes about every 250 ms, not every frame, so the panel isn't itself a cost.",[11,132,133,134,136,137,139,140,146],{},"The gap between ",[88,135,90],{}," and ",[88,138,100],{}," is the most useful signal on the panel. On one of our maps we measured ",[18,141,142,143,145],{},"47 FPS on screen with ",[88,144,100],{}," at 87",". The CPU had room to spare, and the frame was still late. That pointed straight at the GPU, and no amount of instance culling was going to fix it (more on that below).",[11,148,149],{},"Two details made the panel trustworthy:",[151,152,153,160],"ul",{},[154,155,156,159],"li",{},[18,157,158],{},"Counters update on events instead of scanning the board."," Cell effects increment a global count in their create event and decrement it in their clean-up event. Scanning the board to count fires would have cost more than the fires.",[154,161,162,167,168,175],{},[18,163,164,166],{},[88,165,100],{}," is a desktop metric."," The GameMaker manual notes that on the HTML5 target ",[30,169,173],{"href":170,"rel":171},"https:\u002F\u002Fmanual.gamemaker.io\u002Fmonthly\u002Fen\u002FGameMaker_Language\u002FGML_Reference\u002FDebugging\u002Ffps_real.htm",[172],"nofollow",[88,174,100],{}," returns a multiple of the monitor refresh rate instead. We profile on desktop builds.",[23,177,179],{"id":178},"the-cull-pass-deactivating-instances-outside-the-camera","The cull pass: deactivating instances outside the camera",[11,181,182],{},"The core of our GameMaker culling is a small system that deactivates environment objects outside the camera. A deactivated instance runs no Step and no Draw event, which is the cheapest an object can be without being destroyed.",[11,184,185,186,189],{},"In our code each object keeps its logic in an ",[88,187,188],{},"entity"," struct built from small components (a timer, for example). Here is the idea, simplified:",[191,192,197],"pre",{"className":193,"code":194,"language":195,"meta":196,"style":196},"language-gml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","function CullSystem() : Entity() constructor {\n    timer = new TimerComponent(); \u002F\u002F 60-frame cycle\n\n    static execute_culls = function() {\n        \u002F\u002F 1. Wake up everything the camera can see\n        instance_activate_region(CAM_X, CAM_Y, CAM_W, CAM_H, true);\n\n        \u002F\u002F 2. Put back to sleep, type by type, what the camera can't see\n        with (o_environment_object) {\n            if (entity.is_outside_camera()) entity.deactivate();\n        }\n        with (o_puddle) {\n            if (entity.is_outside_camera()) entity.deactivate();\n        }\n        \u002F\u002F ...same for shore foam, wave splashes, sea glints, river wakes, rapids\n    }\n\n    \u002F\u002F 7 passes per 60-frame cycle, not every frame\n    timer.on([1, 10, 20, 30, 40, 50, 59], execute_culls);\n    add_component(timer);\n}\n","gml","",[88,198,199,207,213,220,226,232,238,243,249,255,261,267,273,278,283,289,295,300,306,312,318],{"__ignoreMap":196},[200,201,204],"span",{"class":202,"line":203},"line",1,[200,205,206],{},"function CullSystem() : Entity() constructor {\n",[200,208,210],{"class":202,"line":209},2,[200,211,212],{},"    timer = new TimerComponent(); \u002F\u002F 60-frame cycle\n",[200,214,216],{"class":202,"line":215},3,[200,217,219],{"emptyLinePlaceholder":218},true,"\n",[200,221,223],{"class":202,"line":222},4,[200,224,225],{},"    static execute_culls = function() {\n",[200,227,229],{"class":202,"line":228},5,[200,230,231],{},"        \u002F\u002F 1. Wake up everything the camera can see\n",[200,233,235],{"class":202,"line":234},6,[200,236,237],{},"        instance_activate_region(CAM_X, CAM_Y, CAM_W, CAM_H, true);\n",[200,239,241],{"class":202,"line":240},7,[200,242,219],{"emptyLinePlaceholder":218},[200,244,246],{"class":202,"line":245},8,[200,247,248],{},"        \u002F\u002F 2. Put back to sleep, type by type, what the camera can't see\n",[200,250,252],{"class":202,"line":251},9,[200,253,254],{},"        with (o_environment_object) {\n",[200,256,258],{"class":202,"line":257},10,[200,259,260],{},"            if (entity.is_outside_camera()) entity.deactivate();\n",[200,262,264],{"class":202,"line":263},11,[200,265,266],{},"        }\n",[200,268,270],{"class":202,"line":269},12,[200,271,272],{},"        with (o_puddle) {\n",[200,274,276],{"class":202,"line":275},13,[200,277,260],{},[200,279,281],{"class":202,"line":280},14,[200,282,266],{},[200,284,286],{"class":202,"line":285},15,[200,287,288],{},"        \u002F\u002F ...same for shore foam, wave splashes, sea glints, river wakes, rapids\n",[200,290,292],{"class":202,"line":291},16,[200,293,294],{},"    }\n",[200,296,298],{"class":202,"line":297},17,[200,299,219],{"emptyLinePlaceholder":218},[200,301,303],{"class":202,"line":302},18,[200,304,305],{},"    \u002F\u002F 7 passes per 60-frame cycle, not every frame\n",[200,307,309],{"class":202,"line":308},19,[200,310,311],{},"    timer.on([1, 10, 20, 30, 40, 50, 59], execute_culls);\n",[200,313,315],{"class":202,"line":314},20,[200,316,317],{},"    add_component(timer);\n",[200,319,321],{"class":202,"line":320},21,[200,322,323],{},"}\n",[11,325,326],{},"Three decisions in this code are worth explaining.",[328,329,331],"h3",{"id":330},"activate-a-region-deactivate-by-object-type","Activate a region, deactivate by object type",[11,333,334,341],{},[30,335,338],{"href":336,"rel":337},"https:\u002F\u002Fmanual.gamemaker.io\u002Fmonthly\u002Fen\u002FGameMaker_Language\u002FGML_Reference\u002FAsset_Management\u002FInstances\u002FDeactivating_Instances\u002Finstance_activate_region.htm",[172],[88,339,340],{},"instance_activate_region"," wakes up every deactivated instance whose bounding box overlaps the camera. That's what we want: anything entering the view comes back.",[11,343,344,345,348,349,352],{},"We don't use the matching ",[88,346,347],{},"instance_deactivate_region"," for the other half. It deactivates everything outside the rectangle, with no way to filter by object, and in a tactics game that includes units, controllers and the board itself. Instead, the pass walks a short, explicit list of object types that are safe to put to sleep. Adding a new decorative effect to the cull is one more ",[88,350,351],{},"with"," block. Forgetting to add it means it runs forever, which is exactly what happened with our fire (see below).",[328,354,356],{"id":355},"each-object-decides-what-outside-the-camera-means","Each object decides what \"outside the camera\" means",[11,358,359,360,363],{},"The cull asks each instance ",[88,361,362],{},"is_outside_camera()",", and each type answers in its own way:",[151,365,366,369,372],{},[154,367,368],{},"Generic environment objects test their bounding box against the view.",[154,370,371],{},"Puddles add 32 px of padding, because the water shader draws past the sprite's nominal size.",[154,373,374],{},"Sea foam adds its maximum storm reach plus 16 px, because in a storm the foam travels well beyond its origin.",[11,376,377],{},"Padding matters more than it looks. Without it, an effect whose visuals extend past its bounding box pops in at the edge of the screen when the camera moves.",[328,379,381],{"id":380},"cull-a-few-times-per-cycle-not-every-frame","Cull a few times per cycle, not every frame",[11,383,384],{},"The pass runs 7 times per 60-frame cycle instead of every frame. Camera movement in a tactics game is slow and deliberate, so a few frames of delay before a prop goes to sleep costs nothing visible. The padding above covers the frames before it wakes up.",[11,386,387,388,390,391,393],{},"One detail from the manual explains why this ordering works: an instance reactivated by ",[88,389,340],{}," \"will not be considered to be active until the end of the event\". The ",[88,392,351],{}," loops in the same pass don't see the instances that were just woken up, so a prop entering the view can't be put back to sleep in the same pass that activated it.",[23,395,397],{"id":396},"what-deactivation-doesnt-cull-lights-and-materials","What deactivation doesn't cull: lights and materials",[11,399,400,403],{},[88,401,402],{},"instance_deactivate_object"," stops an instance's events. It doesn't stop anything that instance created elsewhere.",[11,405,406,407,410],{},"Our dynamic lights come from the Crystal lighting library, and a Crystal light is ",[18,408,409],{},"a separate instance"," registered with the renderer. When the cull deactivated a torch, the torch stopped drawing, but its light stayed registered and kept rendering. The same applied to Crystal materials (normal maps and emissive sprites). The Perf panel's \"lights on \u002F total\" counter is there because of this: it counts lights by checking the light instances themselves, not their owners.",[11,412,413,417],{},[41,414],{"alt":415,"src":416},"A night battle in Mokgard: a town square lit by many torches and lamps, units from two armies between the buildings","\u002Fimg\u002Fmokgard-war-of-the-oath\u002Fbattle_05.webp",[46,418,419],{},"A night map. Every lamp and torch here owns a separate light instance that the instance cull doesn't reach on its own.",[11,421,422],{},"Two things keep off-screen lights in check today:",[151,424,425,431],{},[154,426,427,430],{},[18,428,429],{},"Crystal's own culling."," The renderer deactivates dynamic lights whose origin is more than a set distance (the \"border\") outside the camera view. We set that border to 200 px, just above our largest light radius (170 px, the bonfire). It tests only the light's origin, so a long light shape, like a diagonal moonbeam, would switch off with half of it still on screen. We center those shapes on their origin to get around it.",[154,432,433,436],{},[18,434,435],{},"Effects that switch their own light."," A burning cell checks whether it's on screen in its Step event and turns its light on or off to match (next section).",[11,438,439],{},"We also wrote component hooks to switch lights off on deactivation and back on when reactivated, remembering whether the owner wanted the light off in the first place. That code is still in the repo, and it never runs. See the pitfalls section.",[23,441,443],{"id":442},"effects-that-cull-themselves","Effects that cull themselves",[11,445,446],{},"Not everything goes through the cull pass. Our cell effects (fire, poison, mud, corruption and so on) are not on its list, and for a while a dozen fires off screen kept animating, drawing and lighting.",[11,448,449],{},"The fix was to give the effect base class its own visibility check, padded by two cells, and make the expensive effects use it:",[191,451,453],{"className":193,"code":452,"language":195,"meta":196,"style":196},"step(function() {\n    var _visible = _is_on_screen();\n\n    \u002F\u002F An off-screen Crystal light still costs a render pass\n    if (_visible != light_component.is_enabled()) {\n        light_component.set_enabled(_visible);\n    }\n    if (!_visible) return;\n    \u002F\u002F ...rest of the effect step\n});\n\ndraw(function() {\n    if (!_is_on_screen()) return;\n    draw_self();\n});\n",[88,454,455,460,465,469,474,479,484,488,493,498,503,507,512,517,522],{"__ignoreMap":196},[200,456,457],{"class":202,"line":203},[200,458,459],{},"step(function() {\n",[200,461,462],{"class":202,"line":209},[200,463,464],{},"    var _visible = _is_on_screen();\n",[200,466,467],{"class":202,"line":215},[200,468,219],{"emptyLinePlaceholder":218},[200,470,471],{"class":202,"line":222},[200,472,473],{},"    \u002F\u002F An off-screen Crystal light still costs a render pass\n",[200,475,476],{"class":202,"line":228},[200,477,478],{},"    if (_visible != light_component.is_enabled()) {\n",[200,480,481],{"class":202,"line":234},[200,482,483],{},"        light_component.set_enabled(_visible);\n",[200,485,486],{"class":202,"line":240},[200,487,294],{},[200,489,490],{"class":202,"line":245},[200,491,492],{},"    if (!_visible) return;\n",[200,494,495],{"class":202,"line":251},[200,496,497],{},"    \u002F\u002F ...rest of the effect step\n",[200,499,500],{"class":202,"line":257},[200,501,502],{},"});\n",[200,504,505],{"class":202,"line":263},[200,506,219],{"emptyLinePlaceholder":218},[200,508,509],{"class":202,"line":269},[200,510,511],{},"draw(function() {\n",[200,513,514],{"class":202,"line":275},[200,515,516],{},"    if (!_is_on_screen()) return;\n",[200,518,519],{"class":202,"line":280},[200,520,521],{},"    draw_self();\n",[200,523,524],{"class":202,"line":285},[200,525,502],{},[11,527,528],{},"The Step event still runs, so the effect can switch its light back on when the camera returns. It's cheaper than full deactivation would be, and it doesn't depend on any activate\u002Fdeactivate hooks.",[11,530,531,532,535,536,539],{},"We fixed a smaller bug in the same code: the light component's ",[88,533,534],{},"is_enabled()"," read the light's state without checking that the light instance still existed. It now returns ",[88,537,538],{},"false"," when it doesn't.",[23,541,543],{"id":542},"dont-redo-work-the-frame-doesnt-need","Don't redo work the frame doesn't need",[11,545,546],{},"Culling removes objects nobody sees. The other half of a stable frame rate is removing work that visible objects repeat for no reason.",[328,548,550],{"id":549},"puddles-that-redrew-a-surface-every-frame","Puddles that redrew a surface every frame",[11,552,553],{},"Each puddle draws into its own surface with a water shader that animates with time. That surface was rebuilt every frame: set the target, clear it, run the shader, draw 3–6 sprites. Rippling water needs that. Mud and ice don't move, so for them it was wasted work.",[11,555,556,557,560],{},"Now only rippling puddles redraw. Mud and ice draw their surface once and then only call ",[88,558,559],{},"draw_surface_ext",". Their growth animation is frozen and they spawn no drip particles.",[11,562,563,564,567,568,571,572,575],{},"While fixing it we found that ",[88,565,566],{},"set_ripple(false)"," didn't stop the ripple. It passed ",[88,569,570],{},"force = 0",", but the shader's amplitude was ",[88,573,574],{},"0.006 + force * 0.002",", which is never zero. Mud and ice that should have been still were shimmering slightly, and paying for the shader to do it. With ripple off, the shader is now not set at all.",[328,577,579],{"id":578},"the-action-preview-cache-keyed-on-what-can-change","The action preview: cache keyed on what can change",[11,581,582],{},"Mokgard previews every action before the player commits: damage, statuses and which units it affects. We shipped that feature and got an FPS regression the same day. Everything in the preview ran every frame of the GUI draw:",[151,584,585,588,591],{},[154,586,587],{},"The damage calculation fetched the target's defense bonus twice, and that function allocates an array on every call. Worse, the calculation runs twice each time the game resolves a shot, and while the player is aiming it resolves the shot every frame.",[154,589,590],{},"The list of affected targets was rebuilt from scratch every frame: cell occupancy and modifiers checked again, new structs and strings allocated.",[154,592,593],{},"The \"cell under cursor\" lookup ran three times per frame in the same draw call.",[11,595,596,597,600],{},"The fix was a cache keyed on ",[18,598,599],{},"shooter + action + cursor cell + remaining actions",". The last field works as a cheap version number for the game state. Any action spends one, so a preview can never outlive the state it predicted. Repeated lookups moved to the top of the function, and the attack tooltip folded into the same path, so the GUI has one cache and one draw loop instead of two.",[328,602,604],{"id":603},"the-rectangle-drawn-twice-that-looked-fine","The rectangle drawn twice that looked fine",[11,606,607,608,611],{},"Our battlefield drawer paints cell rectangles (movement range, selection, targets) in batches. It runs as two instances, one for the bottom layer and one for the top. A filter meant to send each unit's rectangle to the right layer checked the unit's layer but never compared it with the drawer's. Both drawers matched, both added the rectangle, and ",[18,609,610],{},"every unit and environment rectangle was drawn twice per frame",".",[11,613,614],{},"It looked correct on screen, because two identical rectangles drawn in the same place look like one. We found it while removing another inefficiency in the same drawer: each cell's corners were turned into a center for the Draw event and then back into corners, allocating an array and a struct per cell per frame. The rectangles already had their corners, so now they're drawn as they are.",[23,616,618],{"id":617},"gpu-costs-that-culling-wont-fix","GPU costs that culling won't fix",[11,620,621,622,624],{},"Back to the map at 47 FPS with ",[88,623,100],{}," at 87. The CPU had headroom, so the cost was on the GPU, and it came from shadows.",[11,626,627,628,631],{},"Crystal doesn't clip shadow geometry per light: ",[18,629,630],{},"every light that casts shadows resubmits all the room's shadow geometry",", and the smooth shadow mode does it twice. Each point light also drew its shadow and lighting quad at the size of the whole camera view, however small its radius.",[11,633,634],{},"What we changed:",[636,637,638,648,654],"ol",{},[154,639,640,643,644,647],{},[18,641,642],{},"Patched the renderer"," to clip each light's shadow and quad to the area the light can actually reach (its radius around the light, plus the light's width) with ",[88,645,646],{},"gpu_set_scissor",". The shadow has to be clipped as well as the quad. The light quad resets alpha, which is what clears the shadow mask before the next light, so clipping only the quad would let one light's shadow leak into the next. The patch applies only when the camera isn't rotated, and we mark it in the library source because a library update would overwrite it.",[154,649,650,653],{},[18,651,652],{},"Made shadows opt-in for decorative lights."," The side cones of our lanterns cast no shadow. New decorative lights start without shadows and only get them if the difference shows on screen.",[154,655,656,659],{},[18,657,658],{},"Rebuilt static sprite shadows per change instead of per frame."," Crystal bakes static shadows (units, trees, rocks) into a vertex buffer, and rebuilding it rebuilds all of them. A unit that changes frame switches its shadow to dynamic, and returns to static after 10 frames without changes. While a unit animates, the buffer gets rebuilt when its state changes instead of on every frame.",[23,661,663],{"id":662},"gamemaker-culling-pitfalls-we-hit","GameMaker culling pitfalls we hit",[11,665,666],{},"We learned the most from the failures. Most of them had the same shape. The code and the screen both looked right, and the work was still being done.",[11,668,669,672,673,136,676,679,680,682,683,686,687,689],{},[18,670,671],{},"Activate\u002Fdeactivate hooks that never fire."," Our component framework lets components register ",[88,674,675],{},"on_activate",[88,677,678],{},"on_deactivate"," callbacks. They're stored in one field and the dispatcher looks them up under another name, so they never run. The light and material cleanup we wrote for the cull depends on them. On top of that, ",[88,681,340],{}," reactivates instances directly, without going through the entity's ",[88,684,685],{},"activate()",", so even a fixed dispatcher wouldn't fire ",[88,688,675],{}," after a cull. If we fix the dispatch alone, lights switched off on deactivation would stay off. We now treat a lifecycle hook as broken until we've seen it fire, preferably on a counter in a debug panel.",[11,691,692,695,696,699,700,703],{},[18,693,694],{},"A camera test that checks two corners."," The generic ",[88,697,698],{},"is_inside_camera()"," checks whether the bounding box's top-left ",[46,701,702],{},"or"," bottom-right corner is inside the view. An object larger than the view, or one that overlaps only a corner of it, fails both checks and gets deactivated while partly visible. The sea effects use a real rectangle overlap with padding, and that's the version every type should use.",[11,705,706,709,710,713,714,717,718,721],{},[18,707,708],{},"A timer setter with a typo."," The timer component's ",[88,711,712],{},"set_max_time()"," writes ",[88,715,716],{},"max_time",", a field that doesn't exist, instead of ",[88,719,720],{},"max_timer",". Every timer runs on the default 60-frame cycle whatever it asks for. The cull happened to ask for 60, so it's unaffected. A puddle timer asks for 120 and runs at 60. Fixing it changes the behavior of three systems, so we've logged it and haven't fixed it yet.",[11,723,724,727],{},[18,725,726],{},"A cull margin that only exists in the debug view."," The cull system computes a margin rectangle around the camera center and never uses it. The \"Cull System View\" debug overlay draws that rectangle anyway, so the debug view shows a boundary the cull doesn't apply.",[11,729,730,733],{},[18,731,732],{},"Opt-in culling lists drift."," Every new effect type has to be added to the cull pass by hand. Cell effects weren't, and nobody noticed until a map with a dozen fires showed it on the Perf panel. If we started again, effects would register with the cull in their base class instead of in a list kept somewhere else.",[23,735,737],{"id":736},"takeaways","Takeaways",[11,739,740,741,136,743,745],{},"Instance deactivation is the cheapest culling GameMaker offers, but it only stops the instance's own events. Lights, materials, surfaces and anything else an object creates on its own need their own cleanup, and you need a counter to prove that cleanup happens. Measure with ",[88,742,90],{},[88,744,100],{}," side by side: when they diverge, culling instances won't save you, and the GPU is where to look.",[11,747,748,749,754,755,759],{},"If you want to see how these maps hold up, you can ",[30,750,753],{"href":751,"rel":752},"https:\u002F\u002Fironlizardgames.itch.io\u002Fmokgard-1",[172],"play the current build of Mokgard on itch.io",". The same lamps and torches that cost us light passes are also the subject of our post on ",[30,756,758],{"href":757},"\u002Fblog\u002Fgamemaker-audio-system-vinyl","Mokgard's audio system",", where they became virtual sound emitters.",[23,761,763],{"id":762},"further-reading","Further reading",[151,765,766,774,782,790],{},[154,767,768,773],{},[30,769,771],{"href":336,"rel":770},[172],[88,772,340],{}," in the GameMaker manual: arguments, the bounding-box rule and the end-of-event activation detail.",[154,775,776,781],{},[30,777,779],{"href":170,"rel":778},[172],[88,780,100],{}," in the GameMaker manual: what it measures and how it behaves on HTML5.",[154,783,784,789],{},[30,785,788],{"href":786,"rel":787},"https:\u002F\u002Fgamedeveloper.com\u002Fdesign\u002Fforager-optimization-in-gamemaker",[172],"Forager: optimization in GameMaker"," on Game Developer: deactivating what the player can't see in a large open map.",[154,791,792,797],{},[30,793,796],{"href":794,"rel":795},"https:\u002F\u002Ftombo.itch.io\u002Fcarpoon\u002Fdevlog\u002F88802\u002Foptimizing-carpoon-in-game-maker-studio-2-aka-how-i-made-my-game-less-slow",[172],"Optimizing Carpoon in GameMaker Studio 2",": a devlog on region culling, including where it clashed with the game's car AI.",[799,800,801],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":196,"searchDepth":209,"depth":209,"links":803},[804,805,806,811,812,813,818,819,820,821],{"id":25,"depth":209,"text":26},{"id":51,"depth":209,"text":52},{"id":178,"depth":209,"text":179,"children":807},[808,809,810],{"id":330,"depth":215,"text":331},{"id":355,"depth":215,"text":356},{"id":380,"depth":215,"text":381},{"id":396,"depth":209,"text":397},{"id":442,"depth":209,"text":443},{"id":542,"depth":209,"text":543,"children":814},[815,816,817],{"id":549,"depth":215,"text":550},{"id":578,"depth":215,"text":579},{"id":603,"depth":215,"text":604},{"id":617,"depth":209,"text":618},{"id":662,"depth":209,"text":663},{"id":736,"depth":209,"text":737},{"id":762,"depth":209,"text":763},"2026-11-26","How we cull off-screen instances, lights and effects in GameMaker to keep Mokgard's battles at a stable frame rate, plus the culling bugs that fooled us.",false,"md","mokgard",{},"\u002Fblog\u002Fgamemaker-culling-stable-fps",{"title":5,"description":823},"blog\u002Fgamemaker-culling-stable-fps",[832,833,834,835],"gamemaker","optimization","culling","rendering","wwO4HltuBeyQkjiBhAhiG-zBr23lG6BMG3nSzdEGECw",[838,839],null,{"title":840,"path":757,"stem":841,"children":-1},"A GameMaker Audio System for a Tactics Game","blog\u002Fgamemaker-audio-system-vinyl",1794149269924]