rbxts-vex 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/Config.luau +85 -0
- package/src/GreedyMesher.luau +189 -0
- package/src/VoxelGrid.luau +246 -0
- package/src/VoxelPool.luau +144 -0
- package/src/index.d.ts +100 -0
- package/src/init.luau +226 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rbxts-vex",
|
|
3
|
+
"main": "src/init.luau",
|
|
4
|
+
"types": "src/index.d.ts",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "An efficient, performance-minded voxel destruction model. Created by EternalEthel/qrisquinn (same person).",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "kunosyn",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"roblox-ts",
|
|
11
|
+
"rbxts",
|
|
12
|
+
"voxel",
|
|
13
|
+
"destruction"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rbxtsc --rojo dev.project.json"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@rbxts/compiler-types": "^3.0.0-types.0",
|
|
23
|
+
"@rbxts/types": "^1.0.907",
|
|
24
|
+
"roblox-ts": "^3.0.0",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/Config.luau
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
Config
|
|
3
|
+
|
|
4
|
+
Manages default configuration and validates user-provided settings.
|
|
5
|
+
Centralizes all tunable parameters for the Vex system.
|
|
6
|
+
]]
|
|
7
|
+
|
|
8
|
+
local Config = {}
|
|
9
|
+
|
|
10
|
+
Config.DEFAULTS = {
|
|
11
|
+
voxelSize = 1,
|
|
12
|
+
useGreedyMesh = true,
|
|
13
|
+
maxVoxels = 10000,
|
|
14
|
+
lifetime = nil,
|
|
15
|
+
material = nil,
|
|
16
|
+
collisionGroup = "Debris",
|
|
17
|
+
anchored = false,
|
|
18
|
+
weldAdjacent = true,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Config.LIMITS = {
|
|
22
|
+
MIN_VOXEL_SIZE = 0.1,
|
|
23
|
+
MAX_VOXEL_SIZE = 10,
|
|
24
|
+
MIN_LIFETIME = 1,
|
|
25
|
+
MAX_VOXELS = 50000,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
function Config.validate(userConfig)
|
|
30
|
+
local validated = {}
|
|
31
|
+
|
|
32
|
+
for key, defaultValue in pairs(Config.DEFAULTS) do
|
|
33
|
+
local value = userConfig[key]
|
|
34
|
+
|
|
35
|
+
if value == nil then
|
|
36
|
+
validated[key] = defaultValue
|
|
37
|
+
else
|
|
38
|
+
validated[key] = value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if validated.voxelSize < Config.LIMITS.MIN_VOXEL_SIZE then
|
|
43
|
+
warn(string.format("voxelSize %.2f below minimum, clamping to %.2f",
|
|
44
|
+
validated.voxelSize, Config.LIMITS.MIN_VOXEL_SIZE))
|
|
45
|
+
validated.voxelSize = Config.LIMITS.MIN_VOXEL_SIZE
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if validated.voxelSize > Config.LIMITS.MAX_VOXEL_SIZE then
|
|
49
|
+
warn(string.format("voxelSize %.2f above maximum, clamping to %.2f",
|
|
50
|
+
validated.voxelSize, Config.LIMITS.MAX_VOXEL_SIZE))
|
|
51
|
+
validated.voxelSize = Config.LIMITS.MAX_VOXEL_SIZE
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if validated.maxVoxels > Config.LIMITS.MAX_VOXELS then
|
|
55
|
+
warn(string.format("maxVoxels %d above limit, clamping to %d",
|
|
56
|
+
validated.maxVoxels, Config.LIMITS.MAX_VOXELS))
|
|
57
|
+
validated.maxVoxels = Config.LIMITS.MAX_VOXELS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if validated.lifetime and validated.lifetime < Config.LIMITS.MIN_LIFETIME then
|
|
61
|
+
warn(string.format("lifetime %.1f below minimum, clamping to %.1f",
|
|
62
|
+
validated.lifetime, Config.LIMITS.MIN_LIFETIME))
|
|
63
|
+
validated.lifetime = Config.LIMITS.MIN_LIFETIME
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return validated
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function Config.merge(baseConfig, overrides)
|
|
71
|
+
local merged = {}
|
|
72
|
+
|
|
73
|
+
for key, value in pairs(baseConfig) do
|
|
74
|
+
merged[key] = value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
for key, value in pairs(overrides) do
|
|
78
|
+
merged[key] = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
return merged
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
return Config
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
GreedyMesher
|
|
3
|
+
|
|
4
|
+
Implements greedy meshing algorithm to combine adjacent voxels
|
|
5
|
+
into larger parts, dramatically reducing part count.
|
|
6
|
+
|
|
7
|
+
Algorithm: Scans voxel grid layer by layer, merging rectangular
|
|
8
|
+
regions of same-material voxels into single parts.
|
|
9
|
+
|
|
10
|
+
API:
|
|
11
|
+
- GreedyMesher.mesh(grid) -> meshes[]
|
|
12
|
+
]]
|
|
13
|
+
|
|
14
|
+
local GreedyMesher = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
local function canMerge(voxel1, voxel2)
|
|
18
|
+
if not voxel1 or not voxel2 then
|
|
19
|
+
return false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if voxel1.material ~= voxel2.material then
|
|
23
|
+
return false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if math.abs(voxel1.color.R - voxel2.color.R) > 0.01 then
|
|
27
|
+
return false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if math.abs(voxel1.color.G - voxel2.color.G) > 0.01 then
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if math.abs(voxel1.color.B - voxel2.color.B) > 0.01 then
|
|
35
|
+
return false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
local function createMesh(minX, minY, minZ, maxX, maxY, maxZ, material, color, voxelSize)
|
|
43
|
+
return {
|
|
44
|
+
min = Vector3.new(minX, minY, minZ),
|
|
45
|
+
max = Vector3.new(maxX, maxY, maxZ),
|
|
46
|
+
size = Vector3.new(
|
|
47
|
+
(maxX - minX + 1) * voxelSize,
|
|
48
|
+
(maxY - minY + 1) * voxelSize,
|
|
49
|
+
(maxZ - minZ + 1) * voxelSize
|
|
50
|
+
),
|
|
51
|
+
center = Vector3.new(
|
|
52
|
+
(minX + maxX) / 2 * voxelSize,
|
|
53
|
+
(minY + maxY) / 2 * voxelSize,
|
|
54
|
+
(minZ + maxZ) / 2 * voxelSize
|
|
55
|
+
),
|
|
56
|
+
material = material,
|
|
57
|
+
color = color
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
local function getVoxelKey(x, y, z)
|
|
63
|
+
return string.format("%d,%d,%d", x, y, z)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function GreedyMesher.mesh(grid)
|
|
68
|
+
local meshes = {}
|
|
69
|
+
local processed = {}
|
|
70
|
+
|
|
71
|
+
local minX = grid.bounds.min.X
|
|
72
|
+
local minY = grid.bounds.min.Y
|
|
73
|
+
local minZ = grid.bounds.min.Z
|
|
74
|
+
local maxX = grid.bounds.max.X
|
|
75
|
+
local maxY = grid.bounds.max.Y
|
|
76
|
+
local maxZ = grid.bounds.max.Z
|
|
77
|
+
|
|
78
|
+
for y = minY, maxY do
|
|
79
|
+
for z = minZ, maxZ do
|
|
80
|
+
for x = minX, maxX do
|
|
81
|
+
local key = getVoxelKey(x, y, z)
|
|
82
|
+
|
|
83
|
+
if grid.voxels[key] and not processed[key] then
|
|
84
|
+
local voxel = grid.voxels[key]
|
|
85
|
+
local startX = x
|
|
86
|
+
local endX = x
|
|
87
|
+
|
|
88
|
+
while endX + 1 <= maxX do
|
|
89
|
+
local nextKey = getVoxelKey(endX + 1, y, z)
|
|
90
|
+
local nextVoxel = grid.voxels[nextKey]
|
|
91
|
+
|
|
92
|
+
if not nextVoxel or processed[nextKey] or not canMerge(voxel, nextVoxel) then
|
|
93
|
+
break
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
endX = endX + 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
local endZ = z
|
|
100
|
+
local canExpandZ = true
|
|
101
|
+
|
|
102
|
+
while canExpandZ and endZ + 1 <= maxZ do
|
|
103
|
+
for testX = startX, endX do
|
|
104
|
+
local testKey = getVoxelKey(testX, y, endZ + 1)
|
|
105
|
+
local testVoxel = grid.voxels[testKey]
|
|
106
|
+
|
|
107
|
+
if not testVoxel or processed[testKey] or not canMerge(voxel, testVoxel) then
|
|
108
|
+
canExpandZ = false
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if canExpandZ then
|
|
114
|
+
endZ = endZ + 1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
local endY = y
|
|
119
|
+
local canExpandY = true
|
|
120
|
+
|
|
121
|
+
while canExpandY and endY + 1 <= maxY do
|
|
122
|
+
for testZ = z, endZ do
|
|
123
|
+
for testX = startX, endX do
|
|
124
|
+
local testKey = getVoxelKey(testX, endY + 1, testZ)
|
|
125
|
+
local testVoxel = grid.voxels[testKey]
|
|
126
|
+
|
|
127
|
+
if not testVoxel or processed[testKey] or not canMerge(voxel, testVoxel) then
|
|
128
|
+
canExpandY = false
|
|
129
|
+
break
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if not canExpandY then
|
|
134
|
+
break
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if canExpandY then
|
|
139
|
+
endY = endY + 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
for markY = y, endY do
|
|
144
|
+
for markZ = z, endZ do
|
|
145
|
+
for markX = startX, endX do
|
|
146
|
+
local markKey = getVoxelKey(markX, markY, markZ)
|
|
147
|
+
processed[markKey] = true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
local mesh = createMesh(
|
|
153
|
+
startX, y, z,
|
|
154
|
+
endX, endY, endZ,
|
|
155
|
+
voxel.material,
|
|
156
|
+
voxel.color,
|
|
157
|
+
grid.voxelSize
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
table.insert(meshes, mesh)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return meshes
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
function GreedyMesher.createNaiveMeshes(grid)
|
|
171
|
+
local meshes = {}
|
|
172
|
+
|
|
173
|
+
for key, voxel in pairs(grid.voxels) do
|
|
174
|
+
local mesh = createMesh(
|
|
175
|
+
voxel.x, voxel.y, voxel.z,
|
|
176
|
+
voxel.x, voxel.y, voxel.z,
|
|
177
|
+
voxel.material,
|
|
178
|
+
voxel.color,
|
|
179
|
+
grid.voxelSize
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
table.insert(meshes, mesh)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return meshes
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
return GreedyMesher
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
VoxelGrid
|
|
3
|
+
|
|
4
|
+
Handles conversion of parts into 3D voxel grids.
|
|
5
|
+
Manages coordinate transformations and grid data structures.
|
|
6
|
+
|
|
7
|
+
API:
|
|
8
|
+
- VoxelGrid.fromPart(part, voxelSize) -> grid
|
|
9
|
+
- VoxelGrid.fromModel(model, voxelSize) -> grid
|
|
10
|
+
]]
|
|
11
|
+
|
|
12
|
+
local VoxelGrid = {}
|
|
13
|
+
|
|
14
|
+
local EPSILON = 0.01
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
local function createGrid()
|
|
18
|
+
return {
|
|
19
|
+
voxels = {},
|
|
20
|
+
bounds = {
|
|
21
|
+
min = Vector3.new(math.huge, math.huge, math.huge),
|
|
22
|
+
max = Vector3.new(-math.huge, -math.huge, -math.huge)
|
|
23
|
+
},
|
|
24
|
+
material = Enum.Material.Plastic,
|
|
25
|
+
color = Color3.new(0.5, 0.5, 0.5),
|
|
26
|
+
voxelSize = 1
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
local function worldToGrid(worldPos, origin, voxelSize)
|
|
32
|
+
local relative = worldPos - origin
|
|
33
|
+
|
|
34
|
+
return Vector3.new(
|
|
35
|
+
math.floor(relative.X / voxelSize + 0.5),
|
|
36
|
+
math.floor(relative.Y / voxelSize + 0.5),
|
|
37
|
+
math.floor(relative.Z / voxelSize + 0.5)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
local function gridToWorld(gridPos, origin, voxelSize)
|
|
43
|
+
return origin + Vector3.new(
|
|
44
|
+
gridPos.X * voxelSize,
|
|
45
|
+
gridPos.Y * voxelSize,
|
|
46
|
+
gridPos.Z * voxelSize
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
local function getGridKey(x, y, z)
|
|
52
|
+
return string.format("%d,%d,%d", x, y, z)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
local function addVoxel(grid, x, y, z, material, color)
|
|
57
|
+
local key = getGridKey(x, y, z)
|
|
58
|
+
|
|
59
|
+
grid.voxels[key] = {
|
|
60
|
+
x = x,
|
|
61
|
+
y = y,
|
|
62
|
+
z = z,
|
|
63
|
+
material = material or grid.material,
|
|
64
|
+
color = color or grid.color
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
grid.bounds.min = Vector3.new(
|
|
68
|
+
math.min(grid.bounds.min.X, x),
|
|
69
|
+
math.min(grid.bounds.min.Y, y),
|
|
70
|
+
math.min(grid.bounds.min.Z, z)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
grid.bounds.max = Vector3.new(
|
|
74
|
+
math.max(grid.bounds.max.X, x),
|
|
75
|
+
math.max(grid.bounds.max.Y, y),
|
|
76
|
+
math.max(grid.bounds.max.Z, z)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
function VoxelGrid.fromPart(part, voxelSize)
|
|
82
|
+
if not part:IsA("BasePart") then
|
|
83
|
+
warn("VoxelGrid.fromPart requires a BasePart")
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local grid = createGrid()
|
|
88
|
+
grid.voxelSize = voxelSize
|
|
89
|
+
grid.material = part.Material
|
|
90
|
+
grid.color = part.Color
|
|
91
|
+
|
|
92
|
+
local size = part.Size
|
|
93
|
+
local cframe = part.CFrame
|
|
94
|
+
|
|
95
|
+
local origin = cframe.Position - Vector3.new(
|
|
96
|
+
size.X / 2,
|
|
97
|
+
size.Y / 2,
|
|
98
|
+
size.Z / 2
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
local numVoxelsX = math.max(1, math.ceil(size.X / voxelSize))
|
|
102
|
+
local numVoxelsY = math.max(1, math.ceil(size.Y / voxelSize))
|
|
103
|
+
local numVoxelsZ = math.max(1, math.ceil(size.Z / voxelSize))
|
|
104
|
+
|
|
105
|
+
for x = 0, numVoxelsX - 1 do
|
|
106
|
+
for y = 0, numVoxelsY - 1 do
|
|
107
|
+
for z = 0, numVoxelsZ - 1 do
|
|
108
|
+
local worldPos = origin + Vector3.new(
|
|
109
|
+
(x + 0.5) * voxelSize,
|
|
110
|
+
(y + 0.5) * voxelSize,
|
|
111
|
+
(z + 0.5) * voxelSize
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
local region = Region3.new(
|
|
115
|
+
worldPos - Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2),
|
|
116
|
+
worldPos + Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2)
|
|
117
|
+
):ExpandToGrid(4)
|
|
118
|
+
|
|
119
|
+
local parts = workspace:FindPartsInRegion3(region, part, 1)
|
|
120
|
+
|
|
121
|
+
if #parts > 0 then
|
|
122
|
+
addVoxel(grid, x, y, z, part.Material, part.Color)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
grid.origin = origin
|
|
129
|
+
grid.rotation = cframe - cframe.Position
|
|
130
|
+
|
|
131
|
+
return grid
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
function VoxelGrid.fromModel(model, voxelSize, maxVoxels)
|
|
136
|
+
if not model:IsA("Model") and not model:IsA("BasePart") then
|
|
137
|
+
warn("VoxelGrid.fromModel requires a Model or BasePart")
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
local parts = {}
|
|
142
|
+
|
|
143
|
+
if model:IsA("BasePart") then
|
|
144
|
+
table.insert(parts, model)
|
|
145
|
+
else
|
|
146
|
+
for _, descendant in ipairs(model:GetDescendants()) do
|
|
147
|
+
if descendant:IsA("BasePart") then
|
|
148
|
+
table.insert(parts, descendant)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if #parts == 0 then
|
|
154
|
+
warn("No parts found in model")
|
|
155
|
+
return nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
local grid = createGrid()
|
|
159
|
+
grid.voxelSize = voxelSize
|
|
160
|
+
|
|
161
|
+
local globalMin = Vector3.new(math.huge, math.huge, math.huge)
|
|
162
|
+
local globalMax = Vector3.new(-math.huge, -math.huge, -math.huge)
|
|
163
|
+
|
|
164
|
+
for _, part in ipairs(parts) do
|
|
165
|
+
local size = part.Size
|
|
166
|
+
local pos = part.Position
|
|
167
|
+
|
|
168
|
+
local partMin = pos - size / 2
|
|
169
|
+
local partMax = pos + size / 2
|
|
170
|
+
|
|
171
|
+
globalMin = Vector3.new(
|
|
172
|
+
math.min(globalMin.X, partMin.X),
|
|
173
|
+
math.min(globalMin.Y, partMin.Y),
|
|
174
|
+
math.min(globalMin.Z, partMin.Z)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
globalMax = Vector3.new(
|
|
178
|
+
math.max(globalMax.X, partMax.X),
|
|
179
|
+
math.max(globalMax.Y, partMax.Y),
|
|
180
|
+
math.max(globalMax.Z, partMax.Z)
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
grid.origin = globalMin
|
|
185
|
+
|
|
186
|
+
local voxelCount = 0
|
|
187
|
+
|
|
188
|
+
for _, part in ipairs(parts) do
|
|
189
|
+
local size = part.Size
|
|
190
|
+
local cframe = part.CFrame
|
|
191
|
+
|
|
192
|
+
local numVoxelsX = math.max(1, math.ceil(size.X / voxelSize))
|
|
193
|
+
local numVoxelsY = math.max(1, math.ceil(size.Y / voxelSize))
|
|
194
|
+
local numVoxelsZ = math.max(1, math.ceil(size.Z / voxelSize))
|
|
195
|
+
|
|
196
|
+
for x = 0, numVoxelsX - 1 do
|
|
197
|
+
for y = 0, numVoxelsY - 1 do
|
|
198
|
+
for z = 0, numVoxelsZ - 1 do
|
|
199
|
+
if maxVoxels and voxelCount >= maxVoxels then
|
|
200
|
+
warn(string.format("Reached maxVoxels limit (%d), stopping voxelization", maxVoxels))
|
|
201
|
+
return grid
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
local localPos = Vector3.new(
|
|
205
|
+
(x - numVoxelsX/2 + 0.5) * voxelSize,
|
|
206
|
+
(y - numVoxelsY/2 + 0.5) * voxelSize,
|
|
207
|
+
(z - numVoxelsZ/2 + 0.5) * voxelSize
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
local worldPos = cframe:PointToWorldSpace(localPos)
|
|
211
|
+
local gridPos = worldToGrid(worldPos, grid.origin, voxelSize)
|
|
212
|
+
|
|
213
|
+
addVoxel(grid, gridPos.X, gridPos.Y, gridPos.Z, part.Material, part.Color)
|
|
214
|
+
voxelCount = voxelCount + 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
return grid
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
function VoxelGrid.getVoxelCount(grid)
|
|
225
|
+
local count = 0
|
|
226
|
+
|
|
227
|
+
for _ in pairs(grid.voxels) do
|
|
228
|
+
count = count + 1
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return count
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
function VoxelGrid.getVoxel(grid, x, y, z)
|
|
236
|
+
local key = getGridKey(x, y, z)
|
|
237
|
+
return grid.voxels[key]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
function VoxelGrid.iterator(grid)
|
|
242
|
+
return pairs(grid.voxels)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
return VoxelGrid
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
VoxelPool
|
|
3
|
+
|
|
4
|
+
Object pool for voxel parts. Reuses parts instead of constantly
|
|
5
|
+
creating/destroying them for better performance.
|
|
6
|
+
|
|
7
|
+
API:
|
|
8
|
+
- VoxelPool.get(size, material, color, collisionGroup) -> Part
|
|
9
|
+
- VoxelPool.release(part)
|
|
10
|
+
- VoxelPool.clear()
|
|
11
|
+
]]
|
|
12
|
+
|
|
13
|
+
local VoxelPool = {}
|
|
14
|
+
|
|
15
|
+
local pool = {}
|
|
16
|
+
local activeCount = 0
|
|
17
|
+
|
|
18
|
+
local MAX_POOL_SIZE = 1000
|
|
19
|
+
local CONTAINER_NAME = "VoxelDebris"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
local function getContainer()
|
|
23
|
+
local container = workspace:FindFirstChild(CONTAINER_NAME)
|
|
24
|
+
|
|
25
|
+
if not container then
|
|
26
|
+
container = Instance.new("Folder")
|
|
27
|
+
container.Name = CONTAINER_NAME
|
|
28
|
+
container.Parent = workspace
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
return container
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
local function createKey(size, material, color)
|
|
36
|
+
return string.format("%.1f_%.1f_%.1f_%s_%d_%d_%d",
|
|
37
|
+
size.X, size.Y, size.Z,
|
|
38
|
+
tostring(material),
|
|
39
|
+
math.floor(color.R * 255),
|
|
40
|
+
math.floor(color.G * 255),
|
|
41
|
+
math.floor(color.B * 255)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function VoxelPool.get(size, material, color, collisionGroup)
|
|
47
|
+
local key = createKey(size, material, color)
|
|
48
|
+
|
|
49
|
+
if pool[key] and #pool[key] > 0 then
|
|
50
|
+
local part = table.remove(pool[key])
|
|
51
|
+
part.Anchored = false
|
|
52
|
+
part.CanCollide = true
|
|
53
|
+
part.Transparency = 0
|
|
54
|
+
part.Parent = getContainer()
|
|
55
|
+
activeCount = activeCount + 1
|
|
56
|
+
|
|
57
|
+
return part
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local part = Instance.new("Part")
|
|
61
|
+
part.Size = size
|
|
62
|
+
part.Material = material
|
|
63
|
+
part.Color = color
|
|
64
|
+
part.TopSurface = Enum.SurfaceType.Smooth
|
|
65
|
+
part.BottomSurface = Enum.SurfaceType.Smooth
|
|
66
|
+
part.Anchored = false
|
|
67
|
+
part.CanCollide = true
|
|
68
|
+
part.Parent = getContainer()
|
|
69
|
+
|
|
70
|
+
if collisionGroup then
|
|
71
|
+
part.CollisionGroup = collisionGroup
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
activeCount = activeCount + 1
|
|
75
|
+
|
|
76
|
+
return part
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
function VoxelPool.release(part)
|
|
81
|
+
if not part or not part:IsA("BasePart") then
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
local key = createKey(part.Size, part.Material, part.Color)
|
|
86
|
+
|
|
87
|
+
if not pool[key] then
|
|
88
|
+
pool[key] = {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if #pool[key] >= MAX_POOL_SIZE then
|
|
92
|
+
part:Destroy()
|
|
93
|
+
activeCount = math.max(0, activeCount - 1)
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
for _, child in ipairs(part:GetChildren()) do
|
|
98
|
+
if child:IsA("WeldConstraint") or child:IsA("Weld") then
|
|
99
|
+
child:Destroy()
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
part.Parent = nil
|
|
104
|
+
part.CFrame = CFrame.new(0, -10000, 0)
|
|
105
|
+
part.Anchored = true
|
|
106
|
+
part.CanCollide = false
|
|
107
|
+
part.Velocity = Vector3.new(0, 0, 0)
|
|
108
|
+
part.RotVelocity = Vector3.new(0, 0, 0)
|
|
109
|
+
|
|
110
|
+
table.insert(pool[key], part)
|
|
111
|
+
activeCount = math.max(0, activeCount - 1)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
function VoxelPool.clear()
|
|
116
|
+
for key, parts in pairs(pool) do
|
|
117
|
+
for _, part in ipairs(parts) do
|
|
118
|
+
if part and part:IsA("Instance") then
|
|
119
|
+
part:Destroy()
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
pool = {}
|
|
125
|
+
activeCount = 0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function VoxelPool.getStats()
|
|
130
|
+
local pooledCount = 0
|
|
131
|
+
|
|
132
|
+
for _, parts in pairs(pool) do
|
|
133
|
+
pooledCount = pooledCount + #parts
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
active = activeCount,
|
|
138
|
+
pooled = pooledCount,
|
|
139
|
+
total = activeCount + pooledCount
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
return VoxelPool
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export interface VexOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Bigger voxels = better performance.
|
|
4
|
+
* @default 1
|
|
5
|
+
*/
|
|
6
|
+
voxelSize?: number;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default optimization option, keep it on.
|
|
10
|
+
* @default true
|
|
11
|
+
*/
|
|
12
|
+
useGreedyMesh?: boolean;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Max number of voxels which can be created, mainly a safety measure to prevent lag.
|
|
16
|
+
* @default 10000
|
|
17
|
+
*/
|
|
18
|
+
maxVoxels?: number;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long before cleaning up voxels.
|
|
22
|
+
*/
|
|
23
|
+
lifetime?: number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The material of the voxels.
|
|
27
|
+
*/
|
|
28
|
+
material?: Enum.Material;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The collision group of the voxels.
|
|
32
|
+
* @default "Debris"
|
|
33
|
+
*/
|
|
34
|
+
collisionGroup?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the voxels should be anchored or not.
|
|
38
|
+
* @default false
|
|
39
|
+
*/
|
|
40
|
+
anchored?: boolean;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether to weld adjacent voxels together.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
weldAdjacent?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VoxelStructure {
|
|
50
|
+
/**
|
|
51
|
+
* Destroys the VoxelStructure and its physical Model/BasePart.
|
|
52
|
+
*/
|
|
53
|
+
Destroy(): void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cleans up the voxels in the pool.
|
|
57
|
+
*/
|
|
58
|
+
Cleanup(): void;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Applies Force to voxels in the structure from a specified position.
|
|
62
|
+
* @param force The Force to apply to the voxels.
|
|
63
|
+
* @param position The position at which to apply Force from.
|
|
64
|
+
*/
|
|
65
|
+
ApplyForce(force: Vector3, position: Vector3): void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gets the current number of voxels.
|
|
69
|
+
* @returns The current number of voxels.
|
|
70
|
+
*/
|
|
71
|
+
GetVoxelCount(): number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface VoxelPoolStats {
|
|
75
|
+
readonly active: number;
|
|
76
|
+
readonly pooled: number;
|
|
77
|
+
readonly total: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
declare const Vex: {
|
|
81
|
+
/**
|
|
82
|
+
*
|
|
83
|
+
* @param source The BasePart or Model to voxelize.
|
|
84
|
+
* @param options Configuration settings.
|
|
85
|
+
* @returns A VoxelStructure instance.
|
|
86
|
+
*/
|
|
87
|
+
new(source: BasePart | Model, options?: Readonly<VexOptions>): VoxelStructure;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @returns Stats about the current state of the voxel pool.
|
|
91
|
+
*/
|
|
92
|
+
getPoolStats(): VoxelPoolStats;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Clears the voxel pool.
|
|
96
|
+
*/
|
|
97
|
+
clearPool(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default Vex;
|
package/src/init.luau
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
Vex 2.0 - Main Module
|
|
3
|
+
|
|
4
|
+
High-performance voxel destruction system with greedy meshing
|
|
5
|
+
and object pooling.
|
|
6
|
+
|
|
7
|
+
API:
|
|
8
|
+
Vex.new(model, config) -> VoxelStructure
|
|
9
|
+
|
|
10
|
+
VoxelStructure:Destroy()
|
|
11
|
+
VoxelStructure:ApplyForce(force, position)
|
|
12
|
+
VoxelStructure:Cleanup()
|
|
13
|
+
VoxelStructure:GetVoxelCount()
|
|
14
|
+
]]
|
|
15
|
+
|
|
16
|
+
local Vex = {}
|
|
17
|
+
Vex.__index = Vex
|
|
18
|
+
|
|
19
|
+
local Config = require(script.Config)
|
|
20
|
+
local VoxelGrid = require(script.VoxelGrid)
|
|
21
|
+
local GreedyMesher = require(script.GreedyMesher)
|
|
22
|
+
local VoxelPool = require(script.VoxelPool)
|
|
23
|
+
|
|
24
|
+
local VoxelStructure = {}
|
|
25
|
+
VoxelStructure.__index = VoxelStructure
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
function VoxelStructure.new(source, config)
|
|
29
|
+
local self = setmetatable({}, VoxelStructure)
|
|
30
|
+
|
|
31
|
+
self.source = source
|
|
32
|
+
self.config = Config.validate(config or {})
|
|
33
|
+
self.parts = {}
|
|
34
|
+
self.isDestroyed = false
|
|
35
|
+
self.grid = nil
|
|
36
|
+
self.cleanupConnection = nil
|
|
37
|
+
|
|
38
|
+
return self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
function VoxelStructure:_generateGrid()
|
|
43
|
+
if self.grid then
|
|
44
|
+
return self.grid
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
local grid
|
|
48
|
+
|
|
49
|
+
if self.source:IsA("BasePart") then
|
|
50
|
+
grid = VoxelGrid.fromPart(self.source, self.config.voxelSize)
|
|
51
|
+
else
|
|
52
|
+
grid = VoxelGrid.fromModel(
|
|
53
|
+
self.source,
|
|
54
|
+
self.config.voxelSize,
|
|
55
|
+
self.config.maxVoxels
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if not grid then
|
|
60
|
+
warn("Failed to generate voxel grid")
|
|
61
|
+
return nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
self.grid = grid
|
|
65
|
+
|
|
66
|
+
return grid
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function VoxelStructure:_createParts(meshes)
|
|
71
|
+
local parts = {}
|
|
72
|
+
|
|
73
|
+
for _, mesh in ipairs(meshes) do
|
|
74
|
+
local material = self.config.material or mesh.material
|
|
75
|
+
local part = VoxelPool.get(
|
|
76
|
+
mesh.size,
|
|
77
|
+
material,
|
|
78
|
+
mesh.color,
|
|
79
|
+
self.config.collisionGroup
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
local worldCenter = self.grid.origin + mesh.center
|
|
83
|
+
part.CFrame = CFrame.new(worldCenter)
|
|
84
|
+
part.Anchored = self.config.anchored
|
|
85
|
+
|
|
86
|
+
table.insert(parts, part)
|
|
87
|
+
table.insert(self.parts, part)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return parts
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
function VoxelStructure:_weldAdjacent()
|
|
95
|
+
if not self.config.weldAdjacent then
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
local WELD_DISTANCE = self.config.voxelSize * 1.5
|
|
100
|
+
|
|
101
|
+
for i, part1 in ipairs(self.parts) do
|
|
102
|
+
for j = i + 1, #self.parts do
|
|
103
|
+
local part2 = self.parts[j]
|
|
104
|
+
|
|
105
|
+
local distance = (part1.Position - part2.Position).Magnitude
|
|
106
|
+
|
|
107
|
+
if distance <= WELD_DISTANCE then
|
|
108
|
+
local weld = Instance.new("WeldConstraint")
|
|
109
|
+
weld.Part0 = part1
|
|
110
|
+
weld.Part1 = part2
|
|
111
|
+
weld.Parent = part1
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
function VoxelStructure:_setupLifetime()
|
|
119
|
+
if not self.config.lifetime then
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
task.delay(self.config.lifetime, function()
|
|
124
|
+
self:Cleanup()
|
|
125
|
+
end)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function VoxelStructure:Destroy()
|
|
130
|
+
if self.isDestroyed then
|
|
131
|
+
warn("Structure already destroyed")
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
local grid = self:_generateGrid()
|
|
136
|
+
|
|
137
|
+
if not grid then
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
local meshes
|
|
142
|
+
|
|
143
|
+
if self.config.useGreedyMesh then
|
|
144
|
+
meshes = GreedyMesher.mesh(grid)
|
|
145
|
+
else
|
|
146
|
+
meshes = GreedyMesher.createNaiveMeshes(grid)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
self:_createParts(meshes)
|
|
150
|
+
self:_weldAdjacent()
|
|
151
|
+
self:_setupLifetime()
|
|
152
|
+
|
|
153
|
+
if self.source and self.source.Parent then
|
|
154
|
+
self.source:Destroy()
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
self.isDestroyed = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
function VoxelStructure:ApplyForce(force, position)
|
|
162
|
+
if not self.isDestroyed then
|
|
163
|
+
warn("Call :Destroy() before applying forces")
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
local FORCE_RADIUS = 20
|
|
168
|
+
|
|
169
|
+
for _, part in ipairs(self.parts) do
|
|
170
|
+
if part and part.Parent then
|
|
171
|
+
local distance = (part.Position - position).Magnitude
|
|
172
|
+
|
|
173
|
+
if distance <= FORCE_RADIUS then
|
|
174
|
+
local direction = (part.Position - position).Unit
|
|
175
|
+
local falloff = math.max(0, 1 - (distance / FORCE_RADIUS))
|
|
176
|
+
local scaledForce = force * falloff
|
|
177
|
+
|
|
178
|
+
part.AssemblyLinearVelocity = part.AssemblyLinearVelocity + direction * scaledForce.Magnitude
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
function VoxelStructure:Cleanup()
|
|
186
|
+
for _, part in ipairs(self.parts) do
|
|
187
|
+
if part and part.Parent then
|
|
188
|
+
VoxelPool.release(part)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
self.parts = {}
|
|
193
|
+
|
|
194
|
+
if self.cleanupConnection then
|
|
195
|
+
self.cleanupConnection:Disconnect()
|
|
196
|
+
self.cleanupConnection = nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
function VoxelStructure:GetVoxelCount()
|
|
202
|
+
return #self.parts
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
function Vex.new(source, config)
|
|
207
|
+
if not source then
|
|
208
|
+
warn("Vex.new requires a Model or BasePart")
|
|
209
|
+
return nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return VoxelStructure.new(source, config)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
function Vex.clearPool()
|
|
217
|
+
VoxelPool.clear()
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
function Vex.getPoolStats()
|
|
222
|
+
return VoxelPool.getStats()
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
return Vex
|