├── .gitignore ├── docs ├── priorities.png ├── addons.md ├── intro.md └── usage.md ├── README.md ├── default.project.json ├── wally.toml ├── pesde.toml ├── moonwave.toml ├── src ├── moonwave.luau └── main.luau └── addons └── players.luau /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | pesde.lock -------------------------------------------------------------------------------- /docs/priorities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unityjaeger/QuickBounds/HEAD/docs/priorities.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Check out how to use it in the [Documentation](https://unityjaeger.github.io/QuickBounds/) -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quickbounds", 3 | "tree": { 4 | "$path": "src/main.luau" 5 | } 6 | } -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "unityjaeger/quickbounds" 3 | version = "0.3.2" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | license = "MIT" 7 | include = [ 8 | "src", 9 | "src/**", 10 | "default.project.json", 11 | "wally.toml", 12 | "README.md", 13 | ] 14 | exclude = ["**"] -------------------------------------------------------------------------------- /pesde.toml: -------------------------------------------------------------------------------- 1 | name = "unityjaeger/quickbounds" 2 | version = "0.3.2" 3 | authors = ["unityjaeger"] 4 | license = "MIT" 5 | repository = "https://github.com/unityjaeger/QuickBounds" 6 | 7 | includes = [ 8 | "pesde.toml", 9 | "README.md", 10 | "src/**", 11 | ] 12 | 13 | [indices] 14 | default = "https://github.com/pesde-pkg/index" 15 | 16 | [target] 17 | environment = "roblox" 18 | lib = "src/main.luau" 19 | build_files = ["src"] -------------------------------------------------------------------------------- /moonwave.toml: -------------------------------------------------------------------------------- 1 | title = "QuickBounds" # From Git 2 | gitRepoUrl = "https://github.com/unityjaeger/QuickBounds" # From Git 3 | 4 | gitSourceBranch = "main" 5 | changelog = false 6 | 7 | [home] 8 | enabled = true 9 | includeReadme = false 10 | 11 | [[home.features]] 12 | title = "Fast Spatial Queries" 13 | description = "Uses a Bounding Volume Hierarchy (BVH) for efficient spatial intersection tests" 14 | 15 | [[home.features]] 16 | title = "Group System" 17 | description = "Organize zones and trackable objects into logical groups" 18 | 19 | [[home.features]] 20 | title = "Shapes" 21 | description = "Supports Blocks, Balls, Cylinders and Wedges" 22 | 23 | # From git: 24 | organizationName = "unityjaeger" 25 | projectName = "QuickBounds" 26 | url = "https://github.com/unityjaeger" 27 | baseUrl = "/QuickBounds/" -------------------------------------------------------------------------------- /docs/addons.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Addons 6 | 7 | These are official addons you can find in the [addons](https://github.com/unityjaeger/QuickBounds/tree/main/addons) folder. 8 | 9 | ## [Player Addon](https://github.com/unityjaeger/QuickBounds/tree/main/addons/players.luau) 10 | 11 | This addon abstracts management of the player character away so that it's more comfortable to work with players. 12 | 13 | It exposes a function to add players to groups and a function to remove players from groups. 14 | 15 | Basic usage looks like this: 16 | 17 | ```lua 18 | local exampleGroup = QuickBounds.createGroup() 19 | 20 | local zone = QuickBounds.createZoneFromInstance(workspace.ExampleZone) 21 | zone:watchGroups(exampleGroup) 22 | 23 | game.Players.PlayerAdded:Connect(function(player) 24 | PlayerAddon.addPlayerToGroups(player, exampleGroup) 25 | end) 26 | 27 | exampleGroup:onEntered(function(rootPart, zone, player) 28 | print(player.Name, "entered zone", zone.part) 29 | end) 30 | 31 | exampleGroup:onExited(function(rootPart, zone, player) 32 | print(player.Name, "exited zone", zone.part) 33 | end) 34 | ``` 35 | 36 | Cleanup when players leave is done automatically, so you don't need to manually remove the player from all groups when the player leaves. 37 | 38 | If you want to use this addon with StreamingEnabled on the Client then you have to set the player characters to Persistent on the server. -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Intro 6 | 7 | ## Overview 8 | QuickBounds is a spatial detection module to track BaseParts moving in and out of predefined areas in the world. 9 | It uses a Bounding Volume Hierarchy under the hood to minimize the costs of tracking these BaseParts. 10 | 11 | ## Installation 12 | 13 | For both pesde and wally, the package name + version is 14 | 15 | ``` 16 | unityjaeger/quickbounds@0.3.1 17 | ``` 18 | 19 | Or if you want the source, then just grab it from the latest release from the [Releases](https://github.com/unityjaeger/QuickBounds/releases) tab. 20 | 21 | ## Quick Start 22 | ```lua 23 | --create a group (optionally with a priority) 24 | local group = QuickBounds.createGroup(10) --lower priority groups get prioritized 25 | 26 | --add a basepart to the group to be tracked (optionally with custom data) 27 | group:add(workspace.ExamplePart, "custom data") 28 | 29 | --create a zone 30 | local zone = QuickBounds.createZoneFromInstance(workspace.ExampleZonePart) 31 | 32 | --make the zone start watching the example group 33 | zone:watchGroups(group) 34 | 35 | --register callbacks for zone entering/exiting 36 | group:onEntered(function(part, zone, customData) 37 | --if the zone was registered with createZoneFromInstance then zone.part will be the Instance passed to that function, otherwise it will be nil 38 | print(part, "entered", zone.part, "with custom data", customData) 39 | end) 40 | 41 | group:onExited(function(part, zone, customData) 42 | --if the zone was registered with createZoneFromInstance then zone.part will be the Instance passed to that function, otherwise it will be nil 43 | print(part, "exited", zone.part, "with custom data", customData) 44 | end) 45 | ``` -------------------------------------------------------------------------------- /src/moonwave.luau: -------------------------------------------------------------------------------- 1 | 2 | 3 | --[=[ 4 | @class QuickBounds 5 | ]=] 6 | 7 | --[=[ 8 | @function createZone 9 | @within QuickBounds 10 | @param cframe CFrame 11 | @param size Size 12 | @param shape "Block" | "Ball" | "Cylinder" | "Wedge" 13 | @return Zone 14 | ]=] 15 | 16 | --[=[ 17 | @function createZoneFromInstance 18 | @within QuickBounds 19 | @param part BasePart 20 | @return Zone 21 | Creates and returns a Zone built from an instance, does not support Corner Wedges. 22 | ]=] 23 | 24 | --[=[ 25 | @function setFrameBudget 26 | @within QuickBounds 27 | @param budget number 28 | Sets the frame budget available for each processing frame, in seconds. 29 | ]=] 30 | 31 | --[=[ 32 | @function createGroup 33 | @within QuickBounds 34 | @param priority number? 35 | @return Group 36 | Creates and returns a group, with an optionally specified priority. Default priority is 1e6 (100'000). 37 | ]=] 38 | 39 | --[=[ 40 | @function isPartInGroup 41 | @within QuickBounds 42 | @param part BasePart 43 | @param group Group 44 | @return boolean 45 | Checks if the BasePart is member of the group, not if it's currently physically inside of the group. 46 | ]=] 47 | 48 | --[=[ 49 | @function getPartsForGroup 50 | @within QuickBounds 51 | @param group Group 52 | @return {BasePart} 53 | Returns all parts that are a member of the group. 54 | ]=] 55 | 56 | --[=[ 57 | @function getGroupsForPart 58 | @within QuickBounds 59 | @param part BasePart 60 | @return {Group} 61 | Returns all groups that the BasePart is a member of. 62 | ]=] 63 | 64 | --[=[ 65 | @class Group 66 | ]=] 67 | 68 | --[=[ 69 | @method add 70 | @within Group 71 | @param part BasePart 72 | @param customData any? 73 | Start tracking the specified BasePart. 74 | ]=] 75 | 76 | --[=[ 77 | @method remove 78 | @within Group 79 | @param part BasePart 80 | Makes the group stop tracking the specified BasePart. 81 | ]=] 82 | 83 | --[=[ 84 | @method setPriority 85 | @within Group 86 | @param priority number 87 | Groups with the same priority can be entered by a BasePart that is a member of both groups simultaneously. 88 | If the groups have a different priority then the one with a lower priority will win. 89 | ]=] 90 | 91 | --[=[ 92 | @method onEntered 93 | @within Group 94 | @param callback (part: BasePart, zone: Zone, customData: any?) -> () 95 | @return () -> () 96 | Tracks whenever a BasePart that is a member of this group enters this group through a Zone with the use of a callback function, returns a cleanup function. 97 | ]=] 98 | 99 | --[=[ 100 | @method onExited 101 | @within Group 102 | @param callback (part: BasePart, zone: Zone, customData: any?) -> () 103 | @return () -> () 104 | Tracks whenever a BasePart that is a member of this group exits this group through a Zone with the use of a callback function, returns a cleanup function. 105 | ]=] 106 | 107 | --[=[ 108 | @prop UID number 109 | @within Group 110 | Unique identifier for this group. 111 | ]=] 112 | 113 | --[=[ 114 | @class Zone 115 | ]=] 116 | 117 | --[=[ 118 | @prop part BaseClass? 119 | @within Zone 120 | If available, the BasePart associated with the zone object. 121 | ]=] 122 | 123 | --[=[ 124 | @prop index number 125 | @within Zone 126 | Used for internal data structures. 127 | ]=] 128 | 129 | --[=[ 130 | @method watchGroups 131 | @within Zone 132 | @param ... ...Group 133 | Start watching the specified groups. 134 | ]=] 135 | 136 | --[=[ 137 | @method unwatchGroups 138 | @within Zone 139 | @param ... ...Group 140 | Stop watching the specified groups. 141 | ]=] 142 | 143 | --[=[ 144 | @method destroy 145 | @within Zone 146 | ]=] -------------------------------------------------------------------------------- /addons/players.luau: -------------------------------------------------------------------------------- 1 | --!strict 2 | 3 | --[[very simple usage example 4 | local exampleGroup = QuickBounds.createGroup() 5 | 6 | local zone = QuickBounds.createZoneFromInstance(workspace.ExampleZone) 7 | zone:watchGroups(exampleGroup) 8 | 9 | game.Players.PlayerAdded:Connect(function(player) 10 | PlayerAddon.addPlayerToGroups(player, exampleGroup) 11 | end) 12 | 13 | exampleGroup:onEntered(function(rootPart, zone, player: Player) 14 | print(player, "entered zone", zone.part) 15 | end) 16 | 17 | exampleGroup:onExited(function(rootPart, zone, player: Player) 18 | print(player, "exited zone", zone.part) 19 | end) 20 | ]] 21 | 22 | local Players = game:GetService("Players") 23 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 24 | 25 | local QuickBounds = require(ReplicatedStorage.QuickBounds) 26 | 27 | local characterAddedConnections: {[Player]: RBXScriptConnection} = {} 28 | local characterRemovingConnections: {[Player]: RBXScriptConnection} = {} 29 | 30 | local playerToGroups: {[Player]: {QuickBounds.Group}} = {} 31 | 32 | type Character = Model & {HumanoidRootPart: BasePart?} 33 | 34 | local function findGroupIndexByUID(groups: {QuickBounds.Group}, UID: number): number? 35 | for index, group in groups do 36 | if group.UID == UID then 37 | return index 38 | end 39 | end 40 | return nil 41 | end 42 | 43 | local function addToGroups(player: Player, root: BasePart): () 44 | local playerGroups = playerToGroups[player] 45 | for _, group in playerGroups do 46 | group:add(root, player) 47 | end 48 | end 49 | 50 | local function removeFromGroups(player: Player, root: BasePart): () 51 | local playerGroups = playerToGroups[player] 52 | for _, group in playerGroups do 53 | group:remove(root) 54 | end 55 | end 56 | 57 | local function addPlayerToGroups(player: Player, ...: QuickBounds.Group): () 58 | if not playerToGroups[player] then 59 | playerToGroups[player] = {} 60 | end 61 | 62 | if not characterAddedConnections[player] then 63 | characterAddedConnections[player] = player.CharacterAdded:Connect(function(character) 64 | if not (character :: Character).HumanoidRootPart then 65 | error("[CharacterAdded] " .. player.Name .. " has no HumanoidRootPart") 66 | end 67 | 68 | addToGroups(player, (character :: Character).HumanoidRootPart :: BasePart) 69 | end) 70 | characterRemovingConnections[player] = player.CharacterRemoving:Connect(function(character) 71 | if not (character :: Character).HumanoidRootPart then 72 | error("[CharacterRemoving] " .. player.Name .. " has no HumanoidRootPart") 73 | end 74 | 75 | removeFromGroups(player, (character :: Character).HumanoidRootPart :: BasePart) 76 | end) 77 | end 78 | 79 | local playerGroups = playerToGroups[player] 80 | for _, group in {...} do 81 | table.insert(playerGroups, group) 82 | end 83 | 84 | local root = player.Character and (player.Character :: Character).HumanoidRootPart 85 | if root then 86 | addToGroups(player, root) 87 | end 88 | end 89 | 90 | local function removePlayerFromGroups(player: Player, ...: QuickBounds.Group) 91 | local root = player.Character and (player.Character :: Character).HumanoidRootPart 92 | if not playerToGroups[player] then 93 | return 94 | end 95 | 96 | if not root then 97 | error("could not remove groups from " .. player.Name .. " because player does not have a HumanoidRootPart") 98 | end 99 | 100 | local playerGroups = playerToGroups[player] 101 | 102 | for _, group in {...} do 103 | local index = findGroupIndexByUID(playerGroups, group.UID) 104 | if index then 105 | table.remove(playerGroups, index) 106 | end 107 | 108 | group:remove(root) 109 | end 110 | 111 | if #playerGroups == 0 then 112 | playerToGroups[player] = nil 113 | end 114 | end 115 | 116 | Players.PlayerRemoving:Connect(function(player) 117 | characterAddedConnections[player] = nil 118 | characterRemovingConnections[player] = nil 119 | playerToGroups[player] = nil 120 | end) 121 | 122 | return { 123 | addPlayerToGroups = addPlayerToGroups, 124 | removePlayerFromGroups = removePlayerFromGroups 125 | } 126 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Usage 6 | 7 | ## Zones 8 | 9 | ### Understanding Zones 10 | 11 | Think of a zone as an invisible boundary that defines a spatial area of interest. A zone has mathematical boundaries that define where it begins and ends, as well as a shape. Zones can overlap, intersect and exist in the same space without conflicting with each other. 12 | 13 | A zone is just simple geometry, it knows it's own shape, size and cframe. You can create a zone in one of two ways: Either by manually passing the cframe, size and shape or by creating the zone based on an instance. Every Roblox BasePart shape but Half Wedge is supported. 14 | 15 | ### Creation 16 | 17 | ```lua 18 | --example of manual zone creation 19 | QuickBounds.createZone( 20 | CFrame.new(0, 5, 0), 21 | Vector3.new(10, 10, 10), 22 | "Box" 23 | ) 24 | ``` 25 | 26 | ```lua 27 | --example of zone creation from an instance 28 | QuickBounds.createZoneFromInstance(workspace.ZonePart) 29 | ``` 30 | 31 | What's important to remember is that zones are purely geometric and that they can be used by multiple groups simultaneously if wanted. 32 | 33 | If at any point you want to destroy a zone, you can simply call 34 | 35 | ```lua 36 | zone:destroy() 37 | ``` 38 | 39 | ### Watching Groups 40 | 41 | To start tracking groups you have to tell the zone to watch the groups. 42 | 43 | ```lua 44 | local zone = QuickBounds.createZoneFromInstance(workspace.ZonePart) 45 | zone:watchGroups(ExampleGroup1, ExampleGroup2) 46 | ``` 47 | 48 | You can also tell a zone to stop watching groups. 49 | 50 | ```lua 51 | zone:unwatchGroups(ExampleGroup1) 52 | ``` 53 | 54 | This will be elaborated on further in the next section 55 | 56 | ## Groups 57 | 58 | ### Understanding Groups 59 | 60 | You can think of a group as an observer that watches zones and reacts when objects enter or leave said zones. Each group should ideally embody a particular aspect of your game logic - for example a safezone, quest triggers, traps and so on. 61 | 62 | The relationship between zones and groups is many to many, meaning any number of zones can watch any number of groups. When a zone starts watching a group, the group will be notified if any of its associated BaseParts enter this zone. 63 | 64 | ### Creation and Priority 65 | 66 | To get a group working, you first need to create a group like so: 67 | 68 | ```lua 69 | QuickBounds.createGroup(10) 70 | ``` 71 | 72 | When creating a group you can pass a number parameter that is the priority of the group, the lower the priority of a group the higher the actual priority, it sounds confusing but this is due to an optimization (not needing to pass a function to table.sort during priority resolution, for those who care). So groups with a lower priority value get prioritized over groups with a higher priority value, groups at the same priority can coexist without interferring with one another. 73 | 74 | The above snippet is equivalent to this: 75 | 76 | ```lua 77 | local group = QuickBounds.createGroup() 78 | group:setPriority(10) 79 | ``` 80 | 81 | When no priority is passed during group creation, it defaults to 100'000. 82 | 83 | ![Priority](priorities.png) 84 | 85 | ### Adding and Removing Parts 86 | 87 | If you want a group to start tracking a specific BasePart then you can do the following: 88 | 89 | ```lua 90 | local group = QuickBounds.createGroup() 91 | group:add(workspace.ExamplePart, "Custom Data") 92 | task.wait(5) 93 | group:remove(workspace.ExamplePart) 94 | ``` 95 | 96 | This will register the BasePart with the group, if you want it to be a part of more groups then you have to register it with each group. 97 | 98 | There is also an optional second parameter for add that attaches custom data to a BasePart. 99 | 100 | In this example it will also remove the BasePart from the group after 5 seconds. 101 | 102 | ### Tracking Entry/Exit 103 | 104 | To track entry/exit to a group you can do the following: 105 | 106 | ```lua 107 | local group = QuickBounds.createGroup() 108 | 109 | group:onEntered(function(part: BasePart, zone: QuickBounds.Zone, customData: any?) 110 | print(part, "entered this group") 111 | end) 112 | 113 | group:onExited(function(part: BasePart, zone: QuickBounds.Zone, customData: any?) 114 | print(part, "exited this group") 115 | end) 116 | ``` 117 | 118 | There is no limit to the callbacks that can be registered for a group. 119 | 120 | The first parameter for the callback is the BasePart that interacted with the group, while the second is the specific zone object that the BasePart has entered. If the zone was created via createZoneFromInstance then it will also have a "part" field that can be accessed. The third parameter is the custom data that may or may not have been specified while adding a BasePart to the group, if you want a practical example of custom data in use then check out the Player Addon under the Addons tab. 121 | 122 | Both onEntered and onExited return a cleanup function to remove the callback. 123 | 124 | ## Considerations 125 | 126 | - only the positions of BaseParts are tracked and their size not included for calculations, this means only the center of the BasePart can trigger Entry/Exit, make sure to keep this in mind when working with small zones or big BaseParts -------------------------------------------------------------------------------- /src/main.luau: -------------------------------------------------------------------------------- 1 | --!native 2 | --!optimize 2 3 | --!strict 4 | 5 | local RunService = game:GetService("RunService") 6 | 7 | type ListItem = { 8 | id: number, 9 | mortonCode: number 10 | } 11 | 12 | type Node = { 13 | left: Node?, 14 | right: Node?, 15 | min: Vector3, 16 | max: Vector3, 17 | id: number 18 | } 19 | 20 | type BoundingVolume = { 21 | Shape: "Block" | "Ball" | "Cylinder" | "Wedge", 22 | Position: Vector3, 23 | CFrame: CFrame, 24 | HalfSize: Vector3, 25 | 26 | --for the ball 27 | radius: number?, 28 | 29 | --these are all for optimized cylinder calculations... 30 | start: Vector3?, 31 | axis: Vector3?, 32 | axisLength: number?, 33 | radiusSquared: number? 34 | } 35 | 36 | local objects: {BoundingVolume} = {} 37 | local nextZoneId = 1 38 | 39 | local function unionBounds(nodeA: Node, nodeB: Node): (Vector3, Vector3) 40 | if not nodeA then return nodeB.min, nodeB.max end 41 | if not nodeB then return nodeA.min, nodeA.max end 42 | return vector.min(nodeA.min, nodeB.min), vector.max(nodeA.max, nodeB.max) 43 | end 44 | 45 | local function getObjectBounds(object: BoundingVolume): (Vector3, Vector3) 46 | local halfSize = object.HalfSize 47 | local cframe = object.CFrame 48 | local center = object.Position 49 | 50 | local halfExtent = vector.abs(cframe.RightVector) * halfSize.X + 51 | vector.abs(cframe.UpVector) * halfSize.Y + 52 | vector.abs(cframe.LookVector) * halfSize.Z 53 | 54 | return center - halfExtent, center + halfExtent 55 | end 56 | 57 | local MORTON_SHIFT16_MASK = 0xFF0000FF 58 | local MORTON_SHIFT8_MASK = 0xF00F00F 59 | local MORTON_SHIFT4_MASK = 0x30C30C3 60 | local MORTON_SHIFT2_MASK = 0x9249249 61 | 62 | local expandLUT = {} 63 | for i = 0, 1023 do 64 | local value = i 65 | value = bit32.bor(bit32.lshift(value, 16), value) 66 | value = bit32.band(value, MORTON_SHIFT16_MASK) 67 | value = bit32.bor(bit32.lshift(value, 8), value) 68 | value = bit32.band(value, MORTON_SHIFT8_MASK) 69 | value = bit32.bor(bit32.lshift(value, 4), value) 70 | value = bit32.band(value, MORTON_SHIFT4_MASK) 71 | value = bit32.bor(bit32.lshift(value, 2), value) 72 | value = bit32.band(value, MORTON_SHIFT2_MASK) 73 | expandLUT[i] = value 74 | end 75 | 76 | local function expandBits(value: number): number 77 | return expandLUT[bit32.band(value, 0x3FF)] 78 | end 79 | 80 | local function positionToMortonCode(position: Vector3, sceneBoundsMin: Vector3, sceneBoundsMax: Vector3): number 81 | local scaled = vector.floor( 82 | vector.clamp( 83 | (position - sceneBoundsMin) / (sceneBoundsMax - sceneBoundsMin), 84 | vector.zero, 85 | vector.one 86 | ) * 1023 87 | ) 88 | 89 | return bit32.bor( 90 | expandBits(scaled.x), 91 | bit32.lshift(expandBits(scaled.y), 1), 92 | bit32.lshift(expandBits(scaled.z), 2) 93 | ) 94 | end 95 | 96 | local function getSplitPos(list: {ListItem}, beginIndex: number, endIndex: number): number 97 | if endIndex == beginIndex + 1 then 98 | return beginIndex + 1 99 | end 100 | 101 | local firstCode = list[beginIndex].mortonCode 102 | local lastCode = list[endIndex].mortonCode 103 | 104 | if firstCode == lastCode then 105 | return (beginIndex + endIndex) // 2 106 | end 107 | 108 | local commonPrefix = bit32.countrz(bit32.bxor(firstCode, lastCode)) 109 | 110 | local mid = beginIndex 111 | local step = endIndex - beginIndex 112 | 113 | while step > 1 do 114 | step = math.ceil(step / 2) 115 | local newMid = mid + step 116 | 117 | if newMid < endIndex then 118 | local splitPrefix = bit32.countrz(bit32.bxor(firstCode, list[newMid].mortonCode)) 119 | if splitPrefix > commonPrefix then 120 | mid = newMid 121 | end 122 | end 123 | end 124 | 125 | return mid + 1 126 | end 127 | 128 | local HUGE_VECTOR = Vector3.one * math.huge 129 | local NEGATIVE_HUGE_VECTOR = -HUGE_VECTOR 130 | local function calculateSceneBounds(): (Vector3, Vector3) 131 | local min = HUGE_VECTOR 132 | local max = NEGATIVE_HUGE_VECTOR 133 | 134 | for _, object in objects do 135 | local boundsMin, boundsMax = getObjectBounds(object) 136 | min = vector.min(min, boundsMin) 137 | max = vector.max(max, boundsMax) 138 | end 139 | 140 | local padding = vector.max(vector.one, (max - min) * .01) 141 | return min - padding, max + padding 142 | end 143 | 144 | local function createSubTree(list: {ListItem}, boundsMin: {Vector3}, boundsMax: {Vector3}, beginIndex: number, endIndex: number): Node 145 | if beginIndex == endIndex then 146 | local id = list[beginIndex].id 147 | return { 148 | min = boundsMin[id], 149 | max = boundsMax[id], 150 | id = id 151 | } 152 | else 153 | local mid = getSplitPos(list, beginIndex, endIndex) 154 | local left = createSubTree(list, boundsMin, boundsMax, beginIndex, mid - 1) 155 | local right = createSubTree(list, boundsMin, boundsMax, mid, endIndex) 156 | local min, max = unionBounds(left, right) 157 | return { 158 | min = min, 159 | max = max, 160 | id = -1, 161 | left = left, 162 | right = right 163 | } 164 | end 165 | end 166 | 167 | local function createBVH(): Node? 168 | local sceneBoundsMin, sceneBoundsMax = calculateSceneBounds() 169 | 170 | local list: {ListItem} = {} 171 | local objectBoundsMin = {} 172 | local objectBoundsMax = {} 173 | 174 | for id, object in objects do 175 | local boundsMin, boundsMax = getObjectBounds(object) 176 | objectBoundsMin[id] = boundsMin 177 | objectBoundsMax[id] = boundsMax 178 | 179 | local mortonCode = positionToMortonCode(object.Position, sceneBoundsMin, sceneBoundsMax) 180 | table.insert(list, {id = id, mortonCode = mortonCode}) 181 | end 182 | 183 | table.sort(list, function(a, b) 184 | return a.mortonCode < b.mortonCode 185 | end) 186 | 187 | if #list > 0 then 188 | return createSubTree(list, objectBoundsMin, objectBoundsMax, 1, #list) 189 | else 190 | return nil 191 | end 192 | end 193 | 194 | local function pointIntersection(node: Node, point: Vector3, callback: (nodeId: number) -> ()): () 195 | if not node or vector.clamp(point, node.min, node.max) ~= point then 196 | return nil 197 | end 198 | 199 | if node.id > 0 then 200 | local object = objects[node.id] 201 | 202 | if object.Shape == "Block" then 203 | local localPoint = vector.abs(object.CFrame:PointToObjectSpace(point)) 204 | if vector.min(localPoint, object.HalfSize) == localPoint then 205 | callback(node.id) 206 | end 207 | elseif object.Shape == "Ball" then 208 | if vector.magnitude(object.Position - point) <= object.radius then 209 | callback(node.id) 210 | end 211 | elseif object.Shape == "Cylinder" then 212 | --all these type casts cuz the fields are technically optional 213 | local toPoint = point - object.start :: Vector3 214 | local projection = vector.dot(toPoint, object.axis :: Vector3) 215 | 216 | if projection >= 0 and projection <= object.axisLength :: number then 217 | local closestPointOnAxis = object.start :: Vector3 + object.axis :: Vector3 * projection 218 | local distance = point - closestPointOnAxis 219 | if vector.dot(distance, distance) <= object.radiusSquared :: number then 220 | callback(node.id) 221 | end 222 | end 223 | elseif object.Shape == "Wedge" then 224 | local localPoint = object.CFrame:PointToObjectSpace(point) 225 | local absolutePoint = vector.abs(localPoint) 226 | if vector.min(absolutePoint, object.HalfSize) == absolutePoint then 227 | local size = object.HalfSize * 2 228 | if localPoint.Y/size.Y - localPoint.Z/size.Z <= 0 then 229 | callback(node.id) 230 | end 231 | end 232 | end 233 | 234 | return nil 235 | end 236 | 237 | pointIntersection(node.left :: Node, point, callback) 238 | pointIntersection(node.right :: Node, point, callback) 239 | end 240 | 241 | --for my own sanity 242 | type GroupUID = number 243 | type ZoneIndex = number 244 | 245 | export type Shapes = "Block" | "Ball" | "Cylinder" | "Wedge" 246 | export type Callback = (part: BasePart, zone: Zone, customData: any?) -> () 247 | 248 | export type Zone = { 249 | watchGroups: (zone: Zone, ...Group) -> (), 250 | unwatchGroups: (zone: Zone, ...Group) -> (), 251 | destroy: (zone: Zone) -> (), 252 | index: number, 253 | part: BasePart? 254 | } 255 | 256 | export type Group = { 257 | add: (group: Group, part: BasePart, customData: any?) -> (), 258 | remove: (group: Group, part: BasePart) -> (), 259 | setPriority: (group: Group, priority: number) -> (), 260 | onEntered: (group: Group, callback: Callback) -> () -> (), 261 | onExited: (group: Group, callback: Callback) -> () -> (), 262 | UID: number 263 | } 264 | 265 | local frameBudget = 1/1000 --1ms by default 266 | local nextGroupId = 1 267 | local pendingRebuild = false 268 | local root: Node = nil 269 | 270 | type PartData = { 271 | activeGroupMemberships: {[GroupUID]: ZoneIndex}, 272 | memberOfGroups: {[GroupUID]: boolean} 273 | } 274 | 275 | local partInformation: {[BasePart]: PartData} = {} 276 | local partCustomData: {[BasePart]: {[GroupUID]: any}} = {} 277 | 278 | local groupPriorityMap: {[GroupUID]: number} = {} 279 | local groupUIDToGroupObject: {[GroupUID]: Group} = {} 280 | local groupEnteredCallbackMap: {[GroupUID]: {Callback}} = {} 281 | local groupExitedCallbackMap: {[GroupUID]: {Callback}} = {} 282 | 283 | local zoneWatchingGroups: {[ZoneIndex]: {GroupUID}} = {} 284 | local zoneIndexToZoneObject: {[ZoneIndex]: Zone} = {} 285 | 286 | local function zoneWatchGroups(zone: Zone, ...: Group) 287 | if not zoneWatchingGroups[zone.index] then 288 | zoneWatchingGroups[zone.index] = {} 289 | end 290 | 291 | for _, group in {...} do 292 | table.insert(zoneWatchingGroups[zone.index], group.UID) 293 | end 294 | end 295 | 296 | local function zoneUnwatchGroups(zone: Zone, ...: Group) 297 | if not zoneWatchingGroups[zone.index] then 298 | return 299 | end 300 | 301 | for _, group in {...} do 302 | local index = table.find(zoneWatchingGroups[zone.index], group.UID) 303 | if index then 304 | table.remove(zoneWatchingGroups[zone.index], index) 305 | end 306 | end 307 | 308 | if #zoneWatchingGroups[zone.index] == 0 then 309 | zoneWatchingGroups[zone.index] = nil 310 | end 311 | end 312 | 313 | local function zoneDestroy(zone: Zone): () 314 | zoneWatchingGroups[zone.index] = nil 315 | zoneIndexToZoneObject[zone.index] = nil 316 | objects[zone.index] = nil 317 | pendingRebuild = true 318 | end 319 | 320 | local function createZone(cframe: CFrame, size: Vector3, shape: Shapes, associatedPart: BasePart?): Zone 321 | local halfSize = size / 2 322 | local object: BoundingVolume = { 323 | Position = cframe.Position, 324 | CFrame = cframe, 325 | HalfSize = halfSize, 326 | Shape = shape 327 | } 328 | 329 | if shape == "Ball" then 330 | assert(size.X == size.Y and size.Y == size.Z and size.X == size.Z, "ball does not have a consistent radius") 331 | object.radius = halfSize.X 332 | elseif shape == "Cylinder" then 333 | assert(size.Y == size.Z, "cylinder does not have a consistent radius") 334 | local halfHeight = object.HalfSize.X 335 | local radius = object.HalfSize.Y 336 | 337 | local upVector = object.CFrame.RightVector 338 | local cylinderStart = object.Position - upVector * halfHeight 339 | 340 | object.start = cylinderStart 341 | object.axis = upVector 342 | object.axisLength = halfHeight * 2 343 | object.radiusSquared = radius * radius 344 | end 345 | 346 | local index = nextZoneId 347 | nextZoneId += 1 348 | 349 | objects[index] = object 350 | pendingRebuild = true 351 | 352 | local zone = { 353 | watchGroups = zoneWatchGroups, 354 | unwatchGroups = zoneUnwatchGroups, 355 | destroy = zoneDestroy, 356 | part = associatedPart, 357 | index = index 358 | } 359 | 360 | zoneIndexToZoneObject[index] = zone 361 | 362 | return zone 363 | end 364 | 365 | local function createZoneFromInstance(part: BasePart): Zone 366 | local objectShape: Shapes = "Block" 367 | 368 | if part:IsA("Part") then 369 | local shape = part.Shape 370 | if shape == Enum.PartType.Block then 371 | --do nothing 372 | elseif shape == Enum.PartType.Ball then 373 | objectShape = "Ball" 374 | elseif shape == Enum.PartType.Cylinder then 375 | objectShape = "Cylinder" 376 | elseif shape == Enum.PartType.Wedge then 377 | objectShape = "Wedge" 378 | else 379 | error("part type " .. shape.Name .. " is not supported") 380 | end 381 | end 382 | 383 | return createZone(part.CFrame, part.Size, objectShape, part) 384 | 385 | end 386 | 387 | local function setFrameBudget(budget: number): () 388 | frameBudget = budget 389 | end 390 | 391 | local function groupAdd(group: Group, part: BasePart, customData: any?): () 392 | if not partInformation[part] then 393 | partInformation[part] = { 394 | activeGroupMemberships = {}, 395 | memberOfGroups = {} 396 | } 397 | end 398 | 399 | if not partCustomData[part] then 400 | partCustomData[part] = {} 401 | end 402 | 403 | if customData ~= nil then 404 | partCustomData[part][group.UID] = customData 405 | end 406 | 407 | partInformation[part].memberOfGroups[group.UID] = true 408 | end 409 | 410 | local function groupRemove(group: Group, part: BasePart): () 411 | local partData = partInformation[part] 412 | if not partData then 413 | return 414 | end 415 | 416 | local previousZoneIndex = partData.activeGroupMemberships[group.UID] 417 | 418 | partData.activeGroupMemberships[group.UID] = nil 419 | partData.memberOfGroups[group.UID] = nil 420 | 421 | local previousCustomData 422 | if partCustomData[part] then 423 | previousCustomData = partCustomData[part][group.UID] 424 | partCustomData[part][group.UID] = nil 425 | 426 | if next(partCustomData[part]) == nil then 427 | partCustomData[part] = nil 428 | end 429 | end 430 | 431 | if next(partData.memberOfGroups) == nil then 432 | partInformation[part] = nil 433 | end 434 | 435 | if previousZoneIndex then 436 | local callbacks = groupExitedCallbackMap[group.UID] 437 | if callbacks then 438 | local zone = zoneIndexToZoneObject[previousZoneIndex] 439 | for _, callback in callbacks do 440 | task.spawn(callback, part, zone, previousCustomData) 441 | end 442 | end 443 | end 444 | end 445 | 446 | local function groupSetPriority(group: Group, priority: number): () 447 | groupPriorityMap[group.UID] = priority 448 | end 449 | 450 | local function groupOnEntered(group: Group, callback: Callback): () -> () 451 | table.insert(groupEnteredCallbackMap[group.UID], callback) 452 | local index = #groupEnteredCallbackMap[group.UID] 453 | 454 | return function() 455 | table.remove(groupEnteredCallbackMap[group.UID], index) 456 | end 457 | end 458 | 459 | local function groupOnExited(group: Group, callback: Callback): () -> () 460 | table.insert(groupExitedCallbackMap[group.UID], callback) 461 | local index = #groupExitedCallbackMap[group.UID] 462 | 463 | return function() 464 | table.remove(groupExitedCallbackMap[group.UID], index) 465 | end 466 | end 467 | 468 | local function createGroup(priority: number?): Group 469 | local groupUID = nextGroupId 470 | nextGroupId += 1 471 | 472 | groupPriorityMap[groupUID] = priority or 1e6 473 | groupEnteredCallbackMap[groupUID] = {} 474 | groupExitedCallbackMap[groupUID] = {} 475 | 476 | local group = { 477 | add = groupAdd, 478 | remove = groupRemove, 479 | setPriority = groupSetPriority, 480 | onEntered = groupOnEntered, 481 | onExited = groupOnExited, 482 | UID = groupUID 483 | } 484 | 485 | groupUIDToGroupObject[groupUID] = group 486 | 487 | return group 488 | end 489 | 490 | do 491 | RunService.Heartbeat:Connect(function(deltaTime) 492 | if pendingRebuild then 493 | debug.profilebegin("QuickBounds_build") 494 | 495 | root = createBVH() :: Node 496 | pendingRebuild = false 497 | 498 | debug.profileend() 499 | end 500 | 501 | if #objects == 0 then 502 | return 503 | end 504 | 505 | debug.profilebegin("QuickBounds_step") 506 | 507 | local runTime = 0 508 | local lastProcessedPart 509 | 510 | while runTime <= frameBudget do 511 | local part, partData = next(partInformation, lastProcessedPart) 512 | 513 | lastProcessedPart = part 514 | if not lastProcessedPart then 515 | break 516 | end 517 | 518 | local startTime = os.clock() 519 | 520 | local previousState = table.clone(partData.activeGroupMemberships) 521 | 522 | local currentPosition = (part :: BasePart).Position 523 | 524 | local candidatePriorityByGroup = {} 525 | local candidateIndexByGroup = {} 526 | 527 | debug.profilebegin("gather_candidates") 528 | 529 | pointIntersection(root, currentPosition, function(zoneIndex) 530 | local watchingGroups = zoneWatchingGroups[zoneIndex] 531 | if not watchingGroups then 532 | return 533 | end 534 | 535 | for _, groupUID in watchingGroups do 536 | if partData.memberOfGroups[groupUID] then 537 | local priority = groupPriorityMap[groupUID] 538 | 539 | local existingCandidatePriority = candidatePriorityByGroup[groupUID] 540 | if not existingCandidatePriority or priority < existingCandidatePriority then 541 | candidateIndexByGroup[groupUID] = zoneIndex 542 | candidatePriorityByGroup[groupUID] = priority 543 | end 544 | end 545 | end 546 | end) 547 | 548 | debug.profileend() 549 | 550 | debug.profilebegin("resolve_candidates") 551 | 552 | local candidateGroupByPriority = {} 553 | local priorityLevels = {} 554 | 555 | for groupUID, priority in candidatePriorityByGroup do 556 | if not candidateGroupByPriority[priority] then 557 | candidateGroupByPriority[priority] = {} 558 | end 559 | table.insert(candidateGroupByPriority[priority], groupUID) 560 | table.insert(priorityLevels, priority) 561 | end 562 | 563 | table.sort(priorityLevels) 564 | 565 | local newState = {} 566 | local hasWinnersAtPriority = {} 567 | 568 | for _, priority in priorityLevels do 569 | local candidatesAtThisPriority = candidateGroupByPriority[priority] 570 | 571 | for _, candidateGroupUID in candidatesAtThisPriority do 572 | local newZoneIndex = candidateIndexByGroup[candidateGroupUID] 573 | local previousZoneIndex = previousState[candidateGroupUID] 574 | 575 | newState[candidateGroupUID] = if previousZoneIndex then previousZoneIndex else newZoneIndex 576 | hasWinnersAtPriority[priority] = true 577 | end 578 | end 579 | 580 | local lowestWinningPriority = nil 581 | for _, priority in priorityLevels do 582 | if hasWinnersAtPriority[priority] then 583 | lowestWinningPriority = priority 584 | break 585 | end 586 | end 587 | 588 | if lowestWinningPriority then 589 | local finalState = {} 590 | for groupUID, zoneIndex in newState do 591 | local groupPriority = groupPriorityMap[groupUID] 592 | if groupPriority <= lowestWinningPriority then 593 | finalState[groupUID] = zoneIndex 594 | end 595 | end 596 | newState = finalState 597 | end 598 | 599 | debug.profileend() 600 | 601 | partData.activeGroupMemberships = newState 602 | 603 | debug.profilebegin("fire_callbacks") 604 | 605 | for groupUID, newZoneIndex in newState do 606 | if not previousState[groupUID] then 607 | local callbacks = groupEnteredCallbackMap[groupUID] 608 | if callbacks then 609 | local zone = zoneIndexToZoneObject[newZoneIndex] 610 | local data = partCustomData[part :: BasePart][groupUID] 611 | for _, callback in callbacks do 612 | task.spawn(callback, part :: BasePart, zone, data) 613 | end 614 | end 615 | end 616 | end 617 | 618 | for groupUID, previousZoneIndex in previousState do 619 | if not newState[groupUID] then 620 | local callbacks = groupExitedCallbackMap[groupUID] 621 | if callbacks then 622 | local zone = zoneIndexToZoneObject[previousZoneIndex] 623 | local data = partCustomData[part :: BasePart][groupUID] 624 | for _, callback in callbacks do 625 | task.spawn(callback, part :: BasePart, zone, data) 626 | end 627 | end 628 | end 629 | end 630 | 631 | debug.profileend() 632 | 633 | local endTime = os.clock() 634 | runTime += (endTime - startTime) 635 | end 636 | 637 | debug.profileend() 638 | end) 639 | end 640 | 641 | local function isPartInGroup(part: BasePart, group: Group): boolean 642 | return partInformation[part] and partInformation[part].memberOfGroups[group.UID] 643 | end 644 | 645 | local function getGroupsForPart(part: BasePart): {Group} 646 | if not partInformation[part] then 647 | return {} 648 | end 649 | 650 | local groups = {} 651 | 652 | for groupUID in partInformation[part].memberOfGroups do 653 | table.insert(groups, groupUIDToGroupObject[groupUID]) 654 | end 655 | 656 | return groups 657 | end 658 | 659 | local function getPartsForGroup(group: Group): {BasePart} 660 | local parts = {} 661 | local groupUID = group.UID 662 | 663 | for part, data in partInformation do 664 | if data.memberOfGroups[groupUID] then 665 | table.insert(parts, part) 666 | end 667 | end 668 | 669 | return parts 670 | end 671 | 672 | return { 673 | createZone = createZone, 674 | createZoneFromInstance = createZoneFromInstance, 675 | setFrameBudget = setFrameBudget, 676 | createGroup = createGroup, 677 | 678 | isPartInGroup = isPartInGroup, 679 | getGroupsForPart = getGroupsForPart, 680 | getPartsForGroup = getPartsForGroup 681 | } --------------------------------------------------------------------------------