고체 모델링은 부품을 독특한 방식으로 결합하여 더 복잡한 형태를 형성하는 과정입니다. 여기에는 합집합, 차집합, 교집합이라는 불리언 연산이 포함되며, 이는 일반적으로 구성적 고체 기하학(Constructive Solid Geometry, CSG)으로 알려져 있습니다. 고체 모델링은 스튜디오, 플러그인, 심지어 게임 내에서도 서버와 클라이언트 모두에서 수행할 수 있습니다.
불리언 CSG 외에도 고체 모델링은 물의 밀폐된 메시에 대한 지원과 함께, 플레이어가 기하학을 잘라내고 파편으로 만들어 독특한 게임 플레이 상호작용을 가능하게 하는 스윕 및 파편화와 같은 연산도 지원합니다.


물의 밀폐된 기하학
메시의 기본 요소는 세 가지입니다:
- 정점 - 메시의 단일 점.
- 모서리 - 두 개의 정점을 연결하는 선.
- 면 - 세 개 이상의 정점 사이의 표면 영역.



고체 모델링 연산은 물의 밀폐 기하학으로만 작동할 수 있습니다. 사실, "고체"와 "물의 밀폐"는 동의어입니다. 기술적인 용어로, 메시에 대한 물이 밀폐된 상태는 그것이 닫혀 있고, 매니폴드이며, 자기 교차가 없다는 것을 의미합니다. 이러한 용어는 엄격한 정의가 있지만, 여기 몇 가지 간단한 규칙이 있습니다:
- 각 면은 '안쪽'과 '바깥쪽'이 있어야 합니다. 이는 면의 와인딩 순서로 결정되며, 이는 세 개의 정점의 순서입니다.
- 각 모서리는 정확히 두 개의 삼각형에 의해 공유되어야 합니다. 즉, 메시에는 구멍이 있을 수 없으며, 구멍의 가장자리를 둘러싼 모서리는 오직 하나의 삼각형만 가질 수 있습니다.
- 면은 다른 면을 통과할 수 없습니다.
- 인접한 삼각형은 어떤 쪽이 '바깥쪽'인지를 동의해야 합니다.
- 각 정점은 정확히 하나의 인접 삼각형 팬을 가져야 합니다.

고체 모델링 시스템은 메시와 관련된 특정 작은 문제를 자동으로 수리할 수 있지만, 일반적으로 API 호출은 메시가 물의 밀폐 상태가 아닐 경우 실패합니다. 기존의 비밀폐 메시를 수리하는 데는 일률적인 방법이 없지만, 3D 프린트 툴박스 및 메시 수리 도구와 같은 여러 블렌더 플러그인이 도움이 될 수 있습니다. 또 다른 옵션으로는 메쉬랩도 메시를 매니폴드로 만들려는 유용한 도구들이 내장되어 있습니다. 이는 메시가 물의 밀폐되기에 꼭 필요한 요구사항입니다.
메시가 물의 밀폐 상태가 되기 힘들다는 것을 확인하는 방법 중 하나는 스튜디오에서 모든 각도에서 메시를 살펴본 후 메시의 MeshPart.DoubleSided 속성을 활성화 및 비활성화해 보는 것입니다. 차이가 보인다면 메시가 단순한 껍질일 뿐이며, 위에서 언급한 도구들은 메시의 내부와 외부를 추측할 수 없기 때문에 작동하지 않을 것입니다. 그러나 얇은 메시가 원하는 것이라면 메시의 치수를 정확히 유지할 필요가 없다면, 블렌더의 두께 조정 수정자를 사용하여 껍질을 약간 두껍게 하여 물의 밀폐된 메시로 만들 수 있습니다.

스튜디오에서의 고체 모델링
모델 탭 툴바 내의 네 가지 도구를 사용하여 세 가지 기본 불리언 연산을 수행할 수 있습니다.

| 도구 | 단축키 | 설명 |
|---|---|---|
| 합집합 | ShiftCtrlG (Windows) Shift⌘G (Mac) | 두 개 이상의 부품을 결합하여 단일 고체 합집합을 만듭니다. |
| 교차 | ShiftCtrlI (Windows) Shift⌘I (Mac) | 겹치는 부품을 단일 고체 교차로 만듭니다. |
| 차집합 | ShiftCtrlN (Windows) Shift⌘N (Mac) | 부품을 차집합 화하여 구멍 및 홈을 만드는 데 유용합니다. |
| 분리 | ShiftCtrlU (Windows) Shift⌘U (Mac) | 합집합 또는 교차를 원래의 개별 부품으로 분리합니다. |
합집합 부품
합집합 도구는 두 개 이상의 부품을 결합하여 단일 고체 UnionOperation을 생성합니다.


부품을 합쳐 합집합을 만들려면:
- 함께 결합하고자 하는 모든 부품을 선택합니다.
교차 부품
교차 도구는 겹치는 부품을 단일 고체 IntersectOperation으로 교차합니다.


겹치는 부품을 교차시키려면:
- 교차하고자 하는 모든 부품을 선택합니다.
차집합 부품
차집합 도구는 한 부품을 차집하여 다른 부품과 합쳐질 때 차집합 부품의 모양이 다른 부품에서 빼여지도록 만듭니다.


다른 겹치는 부품에서 차집합을 만들려면:
- 다른 부품에서 차집합할 부품을 선택합니다.
- 차집합을 클릭합니다. 부품이 차집합된 부품으로 태그가 붙고 탐색기에 차집합 기호가 표시됩니다. 부품은 상태를 나타내기 위해 붉은색 음영으로 반투명해집니다.
- 차집합될 부품과 차집합 부품 둘 다 선택합니다.
- 합집합을 클릭합니다. 차집합된 부품이 포함된 겹치는 부품에서 잘립니다.
태그는 스크립팅에 노출되므로, 스크립트나 플러그인을 통해 rbxNegate 태그를 추가하여 부품을 차집합할 수도 있습니다. NegateOperation은 더 이상 사용되지 않습니다.
합집합 또는 교차 분리
분리 도구는 UnionOperation을 원래의 개별 부품으로 되돌립니다. 본질적으로 합집합과 교차에 대한 "실행 취소" 도구와 같습니다.
합집합이나 교차를 개별 부품으로 되돌리려면:
- 합집합이나 교차를 선택합니다.
- 분리를 클릭합니다. 부품이 원래의 형태로 분리됩니다.
게임 내 고체 모델링
게임이 실행되는 동안 GeometryService 함수를 사용하여 고체 모델링 연산을 수행할 수도 있습니다.
UnionAsync(), IntersectAsync(), 및 SubtractAsync()
스튜디오 내의 기본 불리언 연산 도구와 유사하게, 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,
}
-- 비동기이므로 pcall()에서 합집합 연산 수행
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
모든 입력이 MeshParts가 아닌 원시 부품으로 구성된 경우, 합집합, 교차 및 차집합 불리언 연산은 두 개의 저장된 데이터 조각으로 PartOperation을 생성합니다: CSG 연산의 트리인 CSGTree와 렌더링 할 메쉬.
BasePart:UnionAsync()/BasePart:IntersectAsync()/BasePart:SubtractAsync()와 비교하여 GeometryService 불리언 함수는 다음과 같이 다릅니다:
- 출력은 단일 인스턴스가 아닌 인스턴스 배열입니다.
- 입력 부품은 장면에 부모가 필요하지 않으며, 백그라운드에서 작동할 수 있습니다.
- 반환된 모든 부품은 주 부품의 좌표 공간에 있으며, 따라서 그들의 PVInstance.Origin 위치는 주 부품과 동일합니다. 이는 메시의 정점이 원래 부품에 대한 상대적인 동일 위치에 있게 하며, 반환된 부품의 (0, 0, 0)이 바디의 중심에 있을 필요는 없음을 의미합니다.
SweepPartAsync()
GeometryService:SweepPartAsync() 함수는 주어진 일련의 CFrame 위치를 따라 입력 부품의 모양을 드래그하여 MeshPart를 생성합니다. 이 함수는 슬라이스 및 절단 상호작용을 수행하는 데 매우 유용할 수 있습니다.
입력은 Part, PartOperation 또는 MeshPart일 수 있습니다. 결과의 형태는 각 인접한 CFrame 쌍의 볼록 껍질의 합집합으로 정의됩니다. 만약 단일 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

예제

이 예시는 GeometryService:SweepPartAsync()를 사용하여 플레이어의 마우스 위치에 따라 칼이나 레이저 총 슬라이스 게임플레이 기능을 달성합니다. 사용자의 마우스 이동은 CFrames 목록으로 기록되며, SweepPartAsync()는 이 데이터를 기반으로 슬라이스 메시를 빌드한 후, 해당 슬라이스 메시를 충돌한 부품에서 빼냅니다.
이 예제를 스튜디오에서 실행하려면:
모든 고체 모델링 연산을 수행하기 위해 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)다음 LocalScript를 StarterPlayerScripts 내에 만들어 사용자 입력을 처리합니다.
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)DrawCurveEvent라는 이름의 RemoteEvent를 ReplicatedStorage에 생성합니다.
FragmentAsync()
GeometryService:FragmentAsync() 및 GeometryService:GenerateFragmentSites() 함수는 자연스럽게 보이는 형태로 부품을 파편화할 수 있게 해줍니다. GeometryService:FragmentAsync()는 주어진 점의 패턴에 따라 하나의 부품을 여러 MeshPart 인스턴스에 분할하는 보로노이 분해를 사용합니다. 반면에 GeometryService:GenerateFragmentSites()는 FragmentAsync()에 전달할 과정을 생성하는 도움 함수입니다.
이 함수들이 함께 작동하는 방식을 보여주기 위해, 다음 코드 샘플은 블록 부품을 파편화하기 위해 보로노이 사이트를 생성합니다:
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")
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
다음 스크립트는 두 번째 부품을 스텐실로 사용하여 정의된 형태의 첫 번째 부품에서 파편을 나누어 냅니다. 두 번째 부품 내의 보로노이 사이트만이 개별 조각으로 결과를 생성합니다. 모든 다른 사이트는 한 개의 부품으로 결합됩니다.


local GeometryService = game:GetService("GeometryService")
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
다음 스크립트는 훨씬 더 특수한 사용 사례지만 GeometryService:FragmentAsync()에서 반환된 인덱스 데이터의 강력함을 보여줍니다.
예를 들어, 많은 장소에는 비조합된 블록 부품으로 구성된 건물이 있습니다. 수류탄, 포탄, 또는 망치가 이를 손상시킬 경우 모든 벽 부품이 파편화되기를 원할 것입니다. 이 스크립트는 근처의 모든 부품을 파편화한 다음, 서로 다른 부품의 조각을 합쳐서 이음새를 완전히 숨깁니다.
이는 여러 개의 Async 작업을 포함하므로, 사용자 입력에 대한 즉각적인 응답으로 게임 내에서 사용하기에는 적합하지 않을 수 있습니다.


local GeometryService = game:GetService("GeometryService")
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
다음 스크립트는 GeometryService:GenerateFragmentSites()의 거의 동일한 Luau를 대체하는 함수입니다. GeometryService: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
제약 조건 유지하기
입력 부품에 유지하려는 제약 조건이나 부착물이 있는 경우, 이를 결과 부품에 옮길 수 있습니다. 결과 부품이 어떤 제약 조건에 붙어야 할지 아는 것이 번거로울 수 있으므로, GeometryService: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으로 설정할 수 있습니다. 반환된 부품은 주 부품과 동일한 좌표 공간에 있습니다.
인스턴스의 메시 데이터를 교체하여, 원래의 PartOperation을 다른 것으로 완전히 대체하지 않고도 주 부품의 속성, 특성, 태그 및 자식들을 유지하며 연산의 기하학을 활용할 수 있는 함수가 있습니다. 이 접근 방식은 또한 원래 PartOperation을 완전히 대체하는 경우의 잠재적 "깜박임" 문제를 회피합니다.
- 주 부품으로 PartOperation을 사용하고 다른 부품들이 MeshParts이 아닌 경우, SubstituteGeometry()를 통해 다른 PartOperation의 기하학으로 대체할 수 있습니다.
- 주 부품이 MeshPart인 경우에는 MeshPart:ApplyMesh()를 사용할 수 있습니다.
클라이언트에서 이러한 함수를 호출하는 것은 가능하지만 몇 가지 제한이 있습니다. 첫째, 클라이언트에서 생성된 객체에서만 수행해야 합니다. 둘째, 클라이언트에서 서버로의 복제가 불가능합니다.
고체 모델링 결과 고려 사항
색상 및 UV
고체 모델링 후 결과 부품의 색상은 두 곳에서 옵니다: 면 색상과 부품의 Color입니다.
- 결과가 PartOperation인 경우, 스튜디오에서 선택한 첫 번째 부품의 Color를 가지지만, 스튜디오에서는 기본적으로 각각의 면이 연산 이전의 색과 동일하도록 유지하기 위해 면 색상을 따릅니다. 해당 특성의 UsePartColor 속성을 스튜디오에서 활성화하여 이 동작을 덮어쓰고 결과 전체를 단일 색으로 만들 수 있습니다.
UV는 결과의 타입에 따라 다르게 처리됩니다:
- PartOperations는 항상 박스 매핑된 UV를 가지며, 이는 각 면에 방향(-x, +x, -y, +y, -z, +z)에서 하나의 재질/텍스처/데칼이 적용됨을 의미합니다. 이를 통해 텍스처가 늘어날 수 있습니다.
- MeshParts는 박스 매핑되지 않습니다. 주 부품의 메시의 UV가 사용됩니다. Roblox는 현재 다중 재료 지원이 없기 때문에, 다른 부품에서 온 면의 UV는 (0, 0)으로 할당됩니다. 최상의 결과를 얻으려면 텍스처의 픽셀(0, 0)이 합리적인 색상을 가지도록 해야합니다.
스무딩 각도
고체 모델링 부품의 SmoothingAngle 속성은 같은 색상의 인접 표면 사이의 각도를 부드럽게 합니다. 높은 값은 더 매끄러운 외관을 제공하고 낮은 값은 경계가 더 뚜렷하게 됩니다.
30도에서 70도 사이의 값은 일반적으로 좋은 결과를 주지만, 90도에서 180도 사이의 값은 통상적으로 샤프 엣지가 있는 합집합 및 교차에 "그림자 효과"를 유발할 수 있으므로 권장되지 않습니다.


부품 간소화
고체 모델링 연산으로 인해 20,000개 이상의 삼각형을 가진 부품이 생성될 경우, 그들은 20,000으로 간소화됩니다. 그렇게 해도 안되는 경우, 보통 수천 개의 비겹치는 구성 요소와 관련된 경우에 연산이 오류로 이어집니다.

