{"id":"boatbomber/cullthrottle","name":"cullthrottle","scope":"boatbomber","platform":"roblox","description":"Manage effects for tens of thousands of objects, performantly.","version":"0.1.0-rc.10","latest":"0.1.0-rc.10","versions":["0.1.0-rc.1","0.1.0-rc.2","0.1.0-rc.3","0.1.0-rc.4","0.1.0-rc.9","0.1.0-rc.10"],"license":"MPL-2.0","licenseRating":"caution","licenseCaveats":["File-level copyleft: if you modify this package's own source files, those modified files must be made available under MPL-2.0. Using it unmodified in a closed-source game is fine."],"licenseVerified":false,"dependencies":{},"integrity":"7e15a374ddd3a68025c751b2ec14f77af5d336d0dfd206300d299cc634ce8e90","likes":0,"downloads":0,"install":"forest install boatbomber/cullthrottle","url":"https://forest.dev/p/roblox/boatbomber/cullthrottle","files":"https://api.forest.dev/ai/package/roblox/boatbomber/cullthrottle/files","readme":"# CullThrottle\n\nManage effects for tens of thousands of objects, performantly.\n\nCullThrottle is a client-side Roblox Luau library. You hand it every object that wants a small per-frame effect (spinning, bobbing, flickering, pulsing), and each frame it hands back the ones worth updating, most important first, cut off by a time budget. Objects nobody can see cost you nothing, and the objects players are looking at update the most smoothly.\n\n[Please consider supporting my work.](https://github.com/sponsors/boatbomber)\n\n## Installation\n\nVia [wally](https://wally.run):\n\n```toml\n[dependencies]\nCullThrottle = \"boatbomber/cullthrottle@0.1.0-rc.10\"\n```\n\nAlternatively, grab the `.rbxm` standalone model from the latest [release](https://github.com/boatbomber/CullThrottle/releases/latest).\n\n## Quick start\n\n```Luau\nlocal RunService = game:GetService(\"RunService\")\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal CullThrottle = require(ReplicatedStorage:WaitForChild(\"Packages\"):WaitForChild(\"CullThrottle\"))\n\n-- Create 20,000 parts.\nfor i = 1, 20_000 do\n    local block = Instance.new(\"Part\")\n    block.Name = \"SpinningBlock\" .. i\n    block.Size = Vector3.one * math.random(1, 10)\n    block.Color = Color3.fromHSV(math.random(), 0.5, 0.8)\n    block.CFrame = CFrame.new(math.random(-1000, 1000), math.random(-1000, 1000), math.random(-1000, 1000))\n        * CFrame.Angles(math.random(-math.pi, math.pi), math.random(-math.pi, math.pi), math.random(-math.pi, math.pi))\n    block.Anchored = true\n    block.CanCollide = false\n    block.CastShadow = false\n    block:AddTag(\"SpinningBlock\")\n\n    block.Parent = workspace\nend\n\n-- Create a CullThrottle instance.\nlocal SpinningBlocks = CullThrottle.new()\n-- Register all the tagged parts with CullThrottle.\nSpinningBlocks:CaptureTag(\"SpinningBlock\")\n\n-- Every frame, animate the blocks that CullThrottle provides.\nlocal blocks, cframes, blockIndex = {}, {}, 0\nRunService.Heartbeat:Connect(function()\n    blockIndex = 0\n    table.clear(blocks)\n    table.clear(cframes)\n\n    for block, dt, distance, cframe in SpinningBlocks:IterateObjectsToUpdate() do\n        dt = math.min(dt, 1 / 15)\n\n        local angularForce = CFrame.Angles(0, math.rad(90) * dt, 0)\n\n        blockIndex += 1\n        blocks[blockIndex] = block\n        cframes[blockIndex] = cframe * angularForce\n    end\n\n    workspace:BulkMoveTo(blocks, cframes, Enum.BulkMoveMode.FireCFrameChanged)\nend)\n```\n\nFor a richer example, `demo/init.client.luau` drives an interactive scene of spinning blocks with a visibility heatmap and live metric graphs.\n\n## How it works\n\nSuppose your game has fifty thousand objects that each want a small per-frame effect. A frame at 60 FPS gives you about 16 milliseconds for everything the game does, and a loop that merely touches 50,000 objects eats a meaningful slice of that before doing any real work. Updating them all every frame is out of the question. But almost none of those objects are on screen at once, and of the ones that are, the big nearby ones matter far more than the distant specks. The work you actually need each frame is small. The hard part is figuring out which work that is, fast enough that the figuring saves more than it costs.\n\nThe name describes the two halves of the answer. The cull half decides what's visible without asking each object. CullThrottle divides the world into large cubic voxels and tracks which objects occupy each one, so visibility is decided per voxel against the camera's view frustum, and a room packed with a thousand objects costs one verdict instead of a thousand. On top of that, consecutive frames are nearly identical, so every verdict is cached together with how much camera movement it provably survives. On a typical frame, most of the world re-validates with a single comparison per cached answer instead of a fresh geometry test.\n\nThe throttle half decides what the visible objects deserve. Each one is scored, dominated by how large it looms on screen, with smaller corrections so neglected objects gain urgency and nearby ones get a nudge. The scores feed a priority queue, and your update loop drains it under a time budget. Anything that misses a frame comes back more urgent the next, and an object overdue past its worst allowed refresh rate jumps to the front of the line. Every visible object keeps updating, update frequency tracks importance, and under sustained pressure the whole system slows down smoothly instead of letting some objects freeze.\n\nBudgets tie all of it together. Every phase of the per-frame pipeline runs under a fixed time allowance with a defined fallback when it runs out, so a heavy frame degrades the precision of the answers rather than your frame rate. A small controller also floats the render distance between the bounds you configure, shrinking it when the budgets strain and growing it back when there's headroom, so the workload converges to whatever the current scene can afford.\n\n### Going deeper\n\nThat summary is enough to use the library, and the API reference below covers the rest of what you need day to day. If you want to actually understand the machinery, [docs/SYSTEM.md](./docs/SYSTEM.md) walks the entire per-frame pipeline mechanism by mechanism, building up the voxel grid, the frustum test, the motion-proof cache, the search, the priority scoring, and every degradation path, with the goal that CullThrottle goes from black magic to entirely obvious by the end. [docs/MATH.md](./docs/MATH.md) is its companion for the formulas and proofs behind those mechanisms, from the frustum plane construction to the soundness argument for the motion proofs to a ledger of every approximation and the direction it errs. Read SYSTEM.md first, since MATH.md leans on its vocabulary.\n\n## Best practices\n\n1. Use `IterateObjectsToUpdate` for per-frame update logic. It's designed to be called every frame and returns objects in order of importance, so the most important objects are updated first and all visible objects are eventually reached.\n\n2. Prefer BaseParts. While CullThrottle can accept many instance types, it is designed for BaseParts. If you provide an entire model, the bounding box of the model is used for visibility checks and prioritization. If you're only really updating one part of that model, add that part as the object instead.\n\n3. Anchor your BaseParts. A part moved by Roblox's physics engine doesn't fire the CFrame changed event when it moves, so it has to be added with `AddPhysicsObject` so CullThrottle polls its position instead. Polling has a noticeable performance cost and can even produce incorrect visibility if the object moves too quickly.\n\n4. Use tags. CollectionService tags are a powerful way to group objects and let CullThrottle manage them. You can add and remove tags at runtime, and CullThrottle tracks the tagged objects automatically. A BasePart that is unanchored at the moment it's captured is added as a physics object, so anchor your objects before they get picked up if you don't want that (see the previous practice). That routing happens once at capture, so changing the Anchored property later doesn't move an object between static and physics tracking.\n\n## Supported object types\n\nCullThrottle tracks each object using two things, a position (a CFrame) and a bounding box (a size). It derives each one from the object you add, choosing the source based on the object's class. The position source and the bounding box source are resolved independently, so a class can supply one directly while the other comes from an ancestor. For any class not listed below, CullThrottle walks up the object's ancestry until it finds a class it understands, and if it finds none, the object cannot be tracked.\n\n| Class | Position | Bounding box | Notes |\n| --- | --- | --- | --- |\n| `BasePart` | `CFrame` | `Size` | The intended and best supported case. |\n| `Model` | `GetPivot()` | `GetBoundingBox()` | Uses the whole model's bounds. With no `PrimaryPart`, position tracks `WorldPivot`. Prefer adding the specific part you animate (see best practices). |\n| `Bone` | `TransformedWorldCFrame` | nearest ancestor | Position follows the deformed bone, and size comes from the ancestor part. |\n| `Attachment` | `WorldCFrame` | nearest ancestor | Position follows the attachment, and size comes from the ancestor part. |\n| `Beam` | midpoint of `Attachment0` and `Attachment1` | `max(Width0, Width1)` square in cross section, by the attachment-to-attachment distance in length | Requires both `Attachment0` and `Attachment1`. Without them, CullThrottle cannot place or size the beam and warns instead. |\n| `PointLight` / `SpotLight` | nearest ancestor | `Range` cubed (`Vector3.one * Range`) | Position comes from the part or attachment the light sits in. |\n| `Sound` | nearest ancestor | `RollOffMaxDistance` cubed (`Vector3.one * RollOffMaxDistance`) | Position comes from the part or attachment the sound sits in. |\n\nCullThrottle also subscribes to the relevant change signals for whichever source it picked (for example a `BasePart`'s `Size`, a light's `Range`, or a beam's `Width0`/`Width1` and attachment positions), so the position and bounding box stay current as those properties change.\n\nSetting an object's `Parent` to `nil` behaves differently depending on where its sources live. An object whose position or bounding box comes from an ancestor (a light, sound, attachment, or bone) can no longer resolve that source and is dropped from tracking. An object that supplies its own geometry (a `BasePart` or `Model`) stays tracked at its last location, since leaving the world fires no destruction signal. If you pool parts by setting `Parent = nil`, remove them from CullThrottle explicitly.\n\n## API reference\n\nEvery entry below shows the full signature, followed by what it does. Configuration entries pair each setter with its matching getter and list the default.\n\n### Creating and destroying\n\n```Luau\nCullThrottle.new(): CullThrottle\n```\n\nCreates a new CullThrottle instance with reasonable defaults (listed under configuration below) and starts its per-frame processing loop.\n\n```Luau\nCullThrottle:Destroy()\n```\n\nTears down the instance. Disconnects its internal per-frame processing loop, releases all tag and object change listeners, drops all signal handlers, and clears its tracked state so the instance can be garbage collected. Call this when you're done with an instance, and don't use it afterwards.\n\nThis does not destroy or modify the objects you added to CullThrottle. It only stops CullThrottle from tracking them.\n\n### Adding and removing objects\n\n```Luau\nCullThrottle:AddObject(object: Instance)\n```\n\nAdds an object for CullThrottle to track visibility for.\n\n```Luau\nCullThrottle:AddPhysicsObject(object: BasePart)\n```\n\nAdds an object that is moved by physics for CullThrottle to track visibility for. Changed events don't fire for objects moved by Roblox's physics engine, so this method tells CullThrottle to poll the object for position changes instead.\n\n```Luau\nCullThrottle:RemoveObject(object: Instance)\n```\n\nRemoves an object from CullThrottle's tracking.\n\n```Luau\nCullThrottle:CaptureTag(tag: string)\n```\n\nAdds all objects with the given CollectionService tag to CullThrottle's tracking, then listens to the tag's InstanceAdded and InstanceRemoved events so objects are added and removed automatically as the tag set changes.\n\nUnanchored BaseParts are added as physics objects, so be sure to anchor your objects before they get picked up if you don't want that behavior. This routing happens once, when the object is captured. Changing a part's Anchored property later does not move it between static and physics tracking, so re-toggle the tag (or remove and re-add the object) if its anchored state changes.\n\nTracking does not record how an object arrived. When an object loses the last captured tag it carries, it is removed from tracking even if it was also added directly with `AddObject`. Re-add such an object after the tag toggle if you want it to stay tracked.\n\n```Luau\nCullThrottle:ReleaseTag(tag: string)\n```\n\nStops listening to the InstanceAdded and InstanceRemoved events for the given tag. Releasing a tag does not remove the objects that `CaptureTag` already added. Call `RemoveObjectsWithTag` explicitly if you want them removed.\n\n```Luau\nCullThrottle:RemoveObjectsWithTag(tag: string)\n```\n\nRemoves all objects with the given tag from CullThrottle's tracking.\n\n### Reading visibility\n\n```Luau\nCullThrottle:IterateObjectsToUpdate(): () -> (Instance?, number?, number?, CFrame?)\n```\n\nReturns an iterator over this frame's visible objects in update-priority order. Each iteration yields the object, the time in seconds since that particular object's last update (which is what your effect should advance by), the object's distance from the camera, and the object's CFrame. The distance and CFrame are values CullThrottle already computed this frame, handed over so you don't pay to read them again.\n\n```Luau\nRunService.Heartbeat:Connect(function()\n    for object, dt, distance, cframe in CullThrottle:IterateObjectsToUpdate() do\n        -- Update the object here.\n    end\nend)\n```\n\nThe iterator checks the clock as it goes and simply stops when the update time budget runs out. Whatever didn't get updated grows more urgent next frame, so all visible objects are eventually reached. Objects overdue past the worst refresh rate are allowed to run the budget a little over (or far over, with `SetStrictlyEnforceWorstRefreshRate` enabled) so the minimum rate holds up.\n\n```Luau\nCullThrottle:GetVisibleObjects(): { Instance }\n```\n\nReturns all objects that CullThrottle believes to be visible this frame.\n\nCullThrottle does not guarantee that the returned set is exactly the visible set. Under normal conditions it errs on the side of caution, so it may return some objects that are not actually visible. In performance constrained scenarios it is forced to make approximations that may impact accuracy in either direction. If the search budget runs out, CullThrottle reuses last frame's visibility for the volumes it did not have time to re-check, which can momentarily keep returning an object that just left view, or omit one that just entered view, until a later frame catches up. If the ingest budget runs out, CullThrottle dumps the remaining visible objects into the result at a coarse, approximate priority rather than computing a precise one. The returned list contains no duplicates even when these fallbacks are hit.\n\n### Signals\n\n```Luau\nCullThrottle.ObjectAdded: Signal<Instance>\nCullThrottle.ObjectRemoved: Signal<Instance>\n```\n\nFire when an object is added to or removed from CullThrottle's tracking, with the object as the argument. These come in handy with `CaptureTag`, where objects arrive and leave without you calling anything.\n\n```Luau\nCullThrottle.ObjectEnteredView: Signal<Instance>\nCullThrottle.ObjectExitedView: Signal<Instance>\n```\n\nFire when an object joins or leaves the visible set, with the object as the argument. These are for effects that only care about appearing and disappearing rather than per-frame updates.\n\n```Luau\nCullThrottle.ObjectEnteredView:Connect(function(object: Instance)\n    -- The object is now visible.\nend)\n```\n\nBoth signals are buffered during the frame and fired together at the end, after all of CullThrottle's own iteration is finished, so a handler can safely add or remove objects. Every entered event fires before any exited event. Exits are also softened by a short grace period, so an object flickering at the edge of view doesn't fire a storm of events. An object you remove from tracking is evicted from the visible set silently, with `ObjectRemoved` as the only announcement.\n\nA connection to either signal counts as standing demand for visibility, so the pipeline runs every frame even when `SetComputeVisibilityOnlyOnDemand` is enabled.\n\n### Configuration\n\n```Luau\nCullThrottle:SetVoxelSize(voxelSize: number)\nCullThrottle:GetVoxelSize(): number\n```\n\nThe size of the voxels used for visibili","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/boatbomber/roblox/cullthrottle/0.1.0-rc.10/readme"}