实体建模是将 BaseParts 以独特的方式组合在一起以形成更复杂形状的过程。这包括常被称为构造实体几何(CSG)的布尔运算 并集、交集 和 差集。您可以在 Studio、插件 和 游戏内 在服务器和客户端上执行实体建模。
除了布尔 CSG,实体建模还支持网格,只要它们是 密闭的,以及像 扫掠 和 碎片 这样的操作,让您和您的玩家可以切割、切割和破碎几何体,以实现独特的游戏互动。


密闭几何
实体建模操作只能与 密闭 几何体一起使用。从技术上讲,网格是密闭的意味着它是封闭的、可流形的,并且没有自相交。这些术语有严格的定义,但这里有一些简单的规则:
- 每个 面 必须有一个“内部”面和一个“外部”面。这些是由面的缠绕顺序决定的,即其三个顶点的顺序。
- 每个 边 必须被恰好两个三角形共享。这意味着网格中不能有任何孔,因为孔边缘的边只会有一个三角形。
- 面不能穿过其他面。
- 相邻的三角形必须一致地确定哪个面是“外部”面。
- 每个 顶点 必须恰好有一个相邻三角形的扇形。
以下每个示例都是 非密闭的,原因如下:




实体建模系统能够自动修复网格的一些小问题,但一般来说,如果网格不是密闭的,API 调用将失败。没有一种通用的方法可以修复现有的非密闭网格,但有几个 Blender 插件可以帮助,例如 3D 打印工具箱 和 网格修复工具。作为另一种选择,Meshlab 也内置了非常有用的工具,可以尝试使网格成为可流形的,这是网格密闭的主要要求。
查看网格是否极难使其密闭的一种方法是从各个角度在 Studio 中查看它,然后尝试启用和禁用网格的 MeshPart.DoubleSided 属性。如果您能看到任何差异,那么网格只是一个外壳,上述工具将无法工作,因为它们无法猜测网格内部与外部的空间。然而,如果您只想要一个薄网格,并且不重要保持网格的尺寸完全相同,您可以使用 Blender 的 固化修改器 将外壳稍微加厚成一个密闭的网格。


Studio 中的实体建模
您可以使用 模型 选项卡工具栏中的四个工具执行三种基本布尔操作。

| 工具 | 快捷键 | 描述 |
|---|---|---|
| 并集 | ShiftCtrlG (Windows) Shift⌘G (Mac) | 将两个或多个零件组合在一起形成一个单一的实体并集。 |
| 交集 | ShiftCtrlI (Windows) Shift⌘I (Mac) | 将重叠的零件交集成一个单一的实体交集。 |
| 分离 | ShiftCtrlU (Windows) Shift⌘U (Mac) | 将并集或交集分离回其各个部分。 |
| 差集 | ShiftCtrlN (Windows) Shift⌘N (Mac) | 对零件进行差集操作,适用于制作孔和凹陷。 |
并集
并集工具将两个或多个零件组合在一起形成一个单一的实体 UnionOperation。


要将零件组合成并集:
- 选择您想要组合在一起的所有零件。
交集
交集工具将重叠的零件交集成一个单一的实体 IntersectOperation。


要将重叠的零件交集在一起:
- 选择您想要交集的所有零件。
差集
差集工具对一个零件进行差集操作,以便在与另一个零件 并集 时,差集零件的形状将从另一个零件中 减去。


要从其他重叠零件中减去一个零件:
- 选择您想要从其他零件中减去的零件。
- 点击 差集。该零件变得半透明并带有红色阴影以指示其状态。
- 选择减去的零件和您想要从中减去的零件。
- 点击 并集。减去的零件将从包含的重叠零件中切除。
分离
分离工具将 UnionOperation 分离回其各个零件,基本上充当并集和交集的“撤销”工具。
要将并集或交集分离回各个零件:
- 选择并集或交集。
- 点击 分离。零件将分离回其原始形态。
游戏内实体建模
您还可以在游戏运行时或通过 GeometryService 方法从 插件 执行实体建模操作。
核心操作
与 Studio 内置的操作工具类似,您可以使用 GeometryService 方法,如 UnionAsync()、IntersectAsync() 和 SubtractAsync() 来执行基本的布尔操作。例如,以下脚本使用 SubtractAsync() 从另一个零件中减去一个零件的体积。
local GeometryService = game:GetService("GeometryService")
local mainPart = Instance.new("Part")
local otherPart = Instance.new("Part")
otherPart.Position = Vector3.new(1, 0.5, 1)
local success, newParts = pcall(function()
return GeometryService:SubtractAsync(mainPart, {otherPart})
end)
if success and newParts then
for _, newPart in pairs(newParts) do
newPart.Parent = workspace
end
end为了进一步演示,以下代码示例将 mainPart 的几何体与 otherParts 数组中的零件结合在一起,然后销毁参与操作的原始零件。您可以将对 UnionAsync() 的调用替换为 IntersectAsync() 或 SubtractAsync() 来执行其他布尔操作。
local GeometryService = game:GetService("GeometryService")
local mainPart = workspace.BlueBlock
local otherParts = { workspace.PurpleCylinder }
local options = {
CollisionFidelity = Enum.CollisionFidelity.Default,
RenderFidelity = Enum.RenderFidelity.Automatic,
SplitApart = false
}
local success, newParts = pcall(function()
return GeometryService:UnionAsync(mainPart, otherParts, options)
end)
if success and newParts then
-- 循环遍历结果零件以重新父级/重新定位
for _, newPart in pairs(newParts) do
newPart.Parent = mainPart.Parent
newPart.CFrame = mainPart.CFrame
newPart.Anchored = mainPart.Anchored
end
-- 销毁原始零件
mainPart.Parent = nil
mainPart:Destroy()
for _, otherPart in pairs(otherParts) do
otherPart.Parent = nil
otherPart:Destroy()
end
end虽然没有引擎级方法用于零件 差集,但您可以在脚本或 插件 中添加 rbxNegate 标签,以执行与 Studio 的 差集 工具栏按钮相当的差集操作。
local CollectionService = game:GetService("CollectionService")
CollectionService:AddTag(workspace.Part, "rbxNegate")与 BasePart:UnionAsync()/BasePart:IntersectAsync()/BasePart:SubtractAsync() 相比,GeometryService 方法的不同之处在于:
- 输出是一个实例数组,而不是单个实例。
- 输入零件不需要被父级到场景中,允许后台操作。
- 所有返回的零件都在主零件的坐标空间中,因此它们的 PVInstance.Origin 位置与主零件相同。这使得网格的顶点在操作前后相对于对象保持在同一位置,但这也意味着返回零件的 (0, 0, 0) 不一定位于其主体的中心。
扫掠零件
SweepPartAsync() 方法创建一个 MeshPart,其形状是输入零件在给定的一组 CFrame 位置上拖动的形状。此方法对于执行切割和切割交互非常有用。
输入可以是 Part、PartOperation 或 MeshPart。结果的形状被定义为每对相邻的 CFrames 的凸包的并集;如果只提供一个 CFrame,结果将是输入零件的凸包。
为了演示此方法的工作原理,以下代码示例将一个球体在一组 CFrame 位置上扫掠以创建一个螺旋:
local GeometryService = game:GetService("GeometryService")
local inputPart = Instance.new("Part")
inputPart.Shape = Enum.PartType.Ball
local cframeList = {}
for i = 1, 50 do
local rotation = CFrame.Angles(0, i * 0.5, 0)
local position = Vector3.new(0, i * 0.1, -1)
table.insert(cframeList, rotation * CFrame.new(position))
end
local success, sweptPart = pcall(function()
return GeometryService:SweepPartAsync(inputPart, cframeList)
end)
if success and sweptPart then
sweptPart.Parent = workspace
end

此示例使用 SweepPartAsync() 实现剑或激光枪切割游戏功能,其中剑的移动基于玩家的鼠标位置。用户的鼠标移动被记录为 CFrames 的列表,SweepPartAsync() 从这些数据构建切割网格,然后将切割网格从被击中的零件中减去。
要在 Studio 中运行此示例:
在 ServerScriptService 中创建以下 Script 以执行所有实体建模操作。
local ReplicatedStorage = game:GetService("ReplicatedStorage")local GeometryService = game:GetService("GeometryService")local DrawCurveEvent = ReplicatedStorage:WaitForChild("DrawCurveEvent")DrawCurveEvent.OnServerEvent:Connect(function(player, cframeList, hitInstance)local blade = Instance.new("Part")blade.Size = Vector3.new(0.2, 0.2, 15.0)local success, sweptPart = pcall(function()return GeometryService:SweepPartAsync(blade, cframeList)end)if success and sweptPart then-- 可视化扫掠sweptPart.Parent = workspacesweptPart.Transparency = 0.5sweptPart.Anchored = truesweptPart.CanQuery = false-- 从被击中实例中减去扫掠local subtractSuccess, newParts = pcall(function()return GeometryService:SubtractAsync(hitInstance, {sweptPart})end)if subtractSuccess and newParts thenfor _, newPart in pairs(newParts) donewPart.Parent = hitInstance.ParentnewPart.Anchored = trueendhitInstance:Destroy()endendend)在 StarterPlayerScripts 中创建以下 LocalScript 以处理用户输入。
local ReplicatedStorage = game:GetService("ReplicatedStorage")local GeometryService = game:GetService("GeometryService")local DrawCurveEvent = ReplicatedStorage:WaitForChild("DrawCurveEvent")DrawCurveEvent.OnServerEvent:Connect(function(player, cframeList, hitInstance)local blade = Instance.new("Part")blade.Size = Vector3.new(0.2, 0.2, 15.0)local success, sweptPart = pcall(function()return GeometryService:SweepPartAsync(blade, cframeList)end)if success and sweptPart then-- 可视化扫掠sweptPart.Parent = workspacesweptPart.Transparency = 0.5sweptPart.Anchored = truesweptPart.CanQuery = false-- 从被击中实例中减去扫掠local subtractSuccess, newParts = pcall(function()r eturn GeometryService:SubtractAsync(hitInstance, {sweptPart})end)if subtractSuccess and newParts thenfor _, newPart in pairs(newParts) donewPart.Parent = hitInstance.ParentnewPart.Anchored = trueendhitInstance:Destroy()endendend)在 ReplicatedStorage 中创建一个名为 DrawCurveEvent 的 RemoteEvent。
碎片
FragmentAsync() 和 GenerateFragmentSites() 方法允许您将一个零件打碎成自然形状的碎片。FragmentAsync() 使用 Voronoi 分解将单个零件根据传入的点模式划分为多个 MeshPart 实例,而 GenerateFragmentSites() 是一个辅助方法,用于生成称为 Voronoi 站点的点,以传递给 FragmentAsync()。
以下代码示例生成 Voronoi 站点以碎片化一个块零件:
local GeometryService = game:GetService("GeometryService")
local inputPart = Instance.new("Part")
inputPart.Position = Vector3.new(0, 0.7, 20)
local sites = GeometryService:GenerateFragmentSites(inputPart)
local success, fragments = pcall(function()
return GeometryService:FragmentAsync(inputPart, sites)
end)
if success and fragments then
for _, item in fragments do
local instance = item.Instance
instance.Parent = workspace
end
end

以下脚本在给定位置和半径的情况下对零件的一个区域进行碎片化。该位置通常来自物理碰撞或玩家的射线投射。
GenerateFragmentSites() 生成的站点数组的第一个元素将是所有超出请求的 半径 的站点的内部数组。如果您想对零件的剩余“未碎片化部分”执行特定操作,可以通过在循环遍历 FragmentAsync() 的结果时检查 fragments[i].Index == 1 来找到该部分。
local GeometryService = game:GetService("GeometryService")
local function fragmentAtPosition(player, part, contactPoint, radius)
local allSites = GeometryService:GenerateFragmentSites(part, {Origin = contactPoint, Radius = radius})
local success, fragments = pcall(function()
return GeometryService:FragmentAsync(part, allSites)
end)
if not success then
warn("碎片化失败:"..tostring(fragments))
return
end
local decals = {}
for _,child in pairs(part:GetChildren()) do
if child:IsA("Decal") or child:IsA("SurfaceAppearance") then
table.insert(decals,child)
end
end
for i = 1, #fragments do
local fragment = fragments[i].Instance
if fragment == nil then
continue
end
for _,d in pairs(decals) do
local d2 = d:Clone()
d2.Parent = fragment
end
fragment.Anchored = false
fragment.Parent = part.Parent
fragment:SetNetworkOwner(player)
end
if #fragments ~= 0 then
part:Destroy()
end
return fragments
end以下脚本在使用第二个零件作为模板的形状内从第一个零件中打碎碎片。只有在第二个零件内的 Voronoi 站点将导致单独的碎片。所有其他站点将其单元格合并为一个零件。


local GeometryService = game:GetService("GeometryService")
local function fragmentWithinStencil(player, part)
local overlapParams = OverlapParams.new()
overlapParams.FilterType = Enum.RaycastFilterType.Include
overlapParams.FilterDescendantsInstances = {workspace.Stencil}
overlapParams.RespectCanCollide = false
local sensor = Instance.new("Part")
sensor.Size = Vector3.new(0.01, 0.01, 0.01)
local allSites = GeometryService:GenerateFragmentSites(part, {SiteSpacing = 0.9})
local fragmentSites = {}
local mainPartSites = {}
for _, site in ipairs(allSites) do
sensor.CFrame = CFrame.new(site)
local partsFound = workspace:GetPartsInPart(sensor, overlapParams)
if #partsFound > 0 then
table.insert(fragmentSites, site)
else
table.insert(mainPartSites, site)
end
end
local sortedSites = fragmentSites
table.insert(sortedSites, mainPartSites)
workspace.Stencil:Destroy()
local success, fragments = pcall(function()
return GeometryService:FragmentAsync(part, sortedSites, {SplitApart = false})
end)
if not success then
warn("碎片化失败:"..tostring(fragments))
return
end
local decals = {}
for _,child in pairs(part:GetChildren()) do
if child:IsA("Decal") or child:IsA("SurfaceAppearance") then
table.insert(decals,child)
end
end
for i = 1, #fragments do
local fragment = fragments[i].Instance
if fragment == nil then
continue
end
for _,d in pairs(decals) do
local d2 = d:Clone()
d2.Parent = fragment
end
fragment.Anchored = false
fragment.Parent = part.Parent
fragment:SetNetworkOwner(player)
end
if #fragments ~= 0 then
part:Destroy()
end
return fragments
end以下脚本是一个更小众的用例,但它展示了从 FragmentAsync() 返回的索引数据的强大功能。
例如,许多地方包含由多个未联合的块零件形成的建筑。如果手榴弹、炮弹或大锤损坏它,您希望所有墙体零件都被碎片化。此脚本碎片化所有附近的零件,然后将不同零件的碎片联合在一起,以完全隐藏接缝。
这涉及多个 Async() 操作,因此可能不适合在游戏中作为对用户输入的即时响应使用,例如大锤工具。


local GeometryService = game:GetService("GeometryService")
local function fragmentCrossPart(player, part, contactPoint, radius)
local allSites = GeometryService:GenerateFragmentSites(part, {Origin = contactPoint, Radius = radius})
local fragmentsSorted = {}
for i = 1, #allSites do
fragmentsSorted[i] = {}
end
local partsFound = workspace:GetPartBoundsInRadius(contactPoint, radius)
for i, part in ipairs(partsFound) do
local success, fragments = pcall(function()
return GeometryService:FragmentAsync(part, allSites)
end)
if not success then
warn("碎片化失败:"..tostring(fragments))
return
end
for i = 1, #fragments do
local fragment = fragments[i].Instance
local siteIndex = fragments[i].Index
if fragment == nil or siteIndex == nil then
continue
end
table.insert(fragmentsSorted[siteIndex], fragment)
end
end
for i = 1, #fragmentsSorted do
local fragmentList = fragmentsSorted[i]
if #fragmentList == 0 then
continue
end
if #fragmentList == 1 then
local fragment = fragmentList[1]
fragment.Anchored = false
fragment.Parent = part.Parent
fragment:SetNetworkOwner(player)
continue
end
if i == #allSites then
for j = 1, #fragmentList do
local fragment = fragmentList[j]
fragment.Parent = part.Parent
fragment.Anchored = true
end
continue
end
local mainPart = fragmentList[1]
local otherParts = {}
for j = 2, #fragmentList do
table.insert(otherParts, fragmentList[j])
end
local success, results = pcall(function()
return GeometryService:UnionAsync(mainPart, otherParts)
end)
if not success then
warn("联合失败:"..tostring(results))
return
end
for j = 1, #results do
results[j].Parent = part.Parent
results[j].Anchored = false
results[j]:SetNetworkOwner(player)
end
end
for i, part in ipairs(partsFound) do
part:Destroy()
end
end以下脚本是 GenerateFragmentSites() 的几乎相同的 Luau 替代品。如果您想要与 GenerateFragmentSites() 类似的行为,但想要进行一些细微的更改,可以将其用作起点。
它使用抖动的点网格,并保证碎片化区域的良好行为,而不是完全随机的点。
local function generateFragmentSites(part: BasePart, siteSpacing: number?, origin: Vector3?, radius: number?): {Vector3}
local RANDOMNESS_MULTIPLIER = 1.0 -- 用于调整抖动量
if (origin and not radius) or (radius and not origin) then
warn("必须提供原点和半径,或者都不提供。")
return {}
end
local isLocalized = (radius ~= nil) -- isLocalized 意味着不碎片化整个零件,只碎片化一部分。
local partCFrame = part.ExtentsCFrame
local gridDimensions: Vector3
local localGridCenter: Vector3
local spacing
if siteSpacing then
spacing = siteSpacing
elseif isLocalized then
spacing = radius * 0.5
else
local partSize = part.Size
local volume = partSize.X * partSize.Y * partSize.Z
spacing = (volume / 5) ^ (1/3)
end
if isLocalized then
local localOrigin = partCFrame:PointToObjectSpace(origin)
local gridSize = math.ceil(radius * 2 / spacing) + 3
gridDimensions = Vector3.new(gridSize, gridSize, gridSize)
localGridCenter = localOrigin
else
local partSize: Vector3 = part.Size
local xCount = math.ceil(partSize.X / spacing)
local yCount = math.ceil(partSize.Y / spacing)
local zCount = math.ceil(partSize.Z / spacing)
gridDimensions = Vector3.new(xCount, yCount, zCount)
localGridCenter = Vector3.zero
end
local totalGridSize = gridDimensions * spacing
local halfCell = Vector3.new(spacing, spacing, spacing) * 0.5
local localStartOffset = localGridCenter - (totalGridSize * 0.5) + halfCell
local innerJitter = spacing * 0.5 * RANDOMNESS_MULTIPLIER
local outerJitter = math.min(spacing * 0.5 * 0.866, innerJitter)
local sitesFlatList = {}
for x = 0, gridDimensions.X - 1 do
for y = 0, gridDimensions.Y - 1 do
for z = 0, gridDimensions.Z - 1 do
local isOuterShell =
x == 0 or x == gridDimensions.X - 1 or
y == 0 or y == gridDimensions.Y - 1 or
z == 0 or z == gridDimensions.Z - 1
local jitterAmount = if (isOuterShell and isLocalized) then outerJitter else innerJitter
local jitterOffset = Vector3.new(
(math.random() - 0.5) * 2 * jitterAmount,
(math.random() - 0.5) * 2 * jitterAmount,
(math.random() - 0.5) * 2 * jitterAmount
)
local offsetInGrid = Vector3.new(x, y, z) * spacing
table.insert(sitesFlatList, localStartOffset + offsetInGrid + jitterOffset)
end
end
end
local sitesListFinal = {}
if isLocalized then
local mainPartSites = {}
for _, localSite in ipairs(sitesFlatList) do
local worldSite = partCFrame * localSite
local distance = (worldSite - origin).Magnitude
if distance < radius then
table.insert(sitesListFinal, worldSite)
else
table.insert(mainPartSites, worldSite)
end
end
table.insert(sitesListFinal, 1, mainPartSites)
else
for _, localSite in ipairs(sitesFlatList) do
local worldSite = partCFrame * localSite
table.insert(sitesListFinal, worldSite)
end
end
return sitesListFinal
end保留约束
如果输入零件具有您想要保留的约束或附件,您可以将它们转移到结果零件上。确定约束应附加到哪个输出零件可能很繁琐,因此建议使用 CalculateConstraintsToPreserve() 生成一个推荐表,您可以循环遍历并应用。
为了演示,以下代码示例执行一个差集操作,循环遍历结果零件以重新父级和重新定位结果零件,然后计算一个约束和附件的表,以保留或丢弃,然后销毁所有原始零件。
local GeometryService = game:GetService("GeometryService")
local mainPart = workspace.PurpleBlock
local otherParts = { workspace.BlueBlock }
local options = {
CollisionFidelity = Enum.CollisionFidelity.Default,
RenderFidelity = Enum.RenderFidelity.Automatic,
SplitApart = true,
}
local constraintOptions = {
tolerance = 0.1,
weldConstraintPreserve = Enum.WeldConstraintPreserve.All,
dropAttachmentsWithoutConstraints = false,
}
-- 在 pcall() 中执行差集操作,因为它是异步的
local success, newParts = pcall(function()
return GeometryService:SubtractAsync(mainPart, otherParts, options)
end)
if success and newParts then
-- 循环遍历结果零件以重新父级/重新定位
for _, newPart in pairs(newParts) do
newPart.Parent = mainPart.Parent
newPart.CFrame = mainPart.CFrame
newPart.Anchored = mainPart.Anchored
end
-- 计算要保留或丢弃的约束/附件
local recommendedTable = GeometryService:CalculateConstraintsToPreserve(mainPart, newParts, constraintOptions)
-- 根据推荐表保留约束/附件
for _, item in pairs(recommendedTable) do
if item.Attachment then
item.Attachment.Parent = item.AttachmentParent
if item.Constraint then
item.Constraint.Parent = item.ConstraintParent
end
elseif item.NoCollisionConstraint then
local newNoCollision = Instance.new("NoCollisionConstraint")
newNoCollision.Part0 = item.NoCollisionPart0
newNoCollision.Part1 = item.NoCollisionPart1
newNoCollision.Parent = item.NoCollisionParent
elseif item.WeldConstraint then
local newWeldConstraint = Instance.new("WeldConstraint")
newWeldConstraint.Part0 = item.WeldConstraintPart0
newWeldConstraint.Part1 = item.WeldConstraintPart1
newWeldConstraint.Parent = item.WeldConstraintParent
end
end
-- 销毁原始零件
mainPart.Parent = nil
mainPart:Destroy()
for _, otherPart in pairs(otherParts) do
otherPart.Parent = nil
otherPart:Destroy()
end
end实体建模结果
行为细节
如果主零件在操作计算期间移动,您可以将结果零件设置为主零件的更新 CFrame,因为返回的零件与主零件在同一坐标空间中。
如果实体建模操作会导致任何零件具有超过 20,000 个三角形,它们将被简化为 20,000。如果无法做到,通常在数千个不重叠组件的情况下,操作将导致错误。
有方法可以替换实例的网格数据,使其更容易利用操作的几何体,同时保持主零件的属性、属性、标签和子项,例如 Attachments、Constraints、ParticleEmitters、光源对象和贴花。此方法还避免了完全用另一个 PartOperation 替换原始零件的潜在“闪烁”。
- 如果使用此方法将 PartOperation 作为主零件,并且其他零件都不是 MeshParts,您可以通过 SubstituteGeometry() 替换另一个 PartOperation 的几何体。
- 如果主零件是 MeshPart,您可以使用 MeshPart:ApplyMesh()。
可以在客户端调用这些方法,但有一些限制。首先,必须使用 在客户端创建 的对象。其次,客户端到服务器没有可用的复制。
颜色和 UV
实体建模后结果零件的颜色来自两个地方:面颜色和零件的 Color。
- 如果结果是 PartOperation,它将具有您在 Studio 中选择的第一个零件的 Color,但 Studio 默认使用面颜色以保持每个面与操作前相同的颜色。您可以在 Studio 中启用其 UsePartColor 属性以覆盖此行为,使整个结果为单一颜色。
UV 也根据结果的类型以不同方式处理:
- MeshParts 不进行盒映射。主零件网格的 UV 被使用。由于 Roblox 当前不支持多材料,来自其他零件的面将被赋予 (0, 0) 的 UV。为了获得最佳效果,请确保纹理的像素 (0, 0) 具有合理的颜色。
平滑角度
实体建模零件的 SmoothingAngle 属性平滑相同颜色的相邻表面之间的角度。较高的值产生更平滑的外观,而较低的值则产生更粗糙的外观,边缘更锋利。
虽然 30 到 70 度之间的值通常会产生良好的结果,但 90 到 180 度之间的值不推荐,因为它们可能会在具有锐利边缘的并集和交集上造成“阴影”效果。

