
Hai phần trước chỉ cắt pixel. Dù tan kiểu gì, bề mặt còn lại vẫn nằm yên đúng chỗ cũ, như một tờ giấy bị đục lỗ. Muốn vật trông như đang cháy, đang sôi hay đang bị hút đi thì chính hình khối phải chuyển động theo mép cắt.
Bài này thêm hai kiểu chuyển động cho vertex:
- Đẩy (push): vỏ phồng ra ở đúng chỗ đang cháy, rồi xẹp lại khi mép cắt đi qua. Ý tưởng lấy từ bài Vertical Dissolve của Minions Art.
- Kéo (pull): phần đã tan bị kéo dài rồi hút về một điểm, như bị cổng không gian nuốt.
Đẩy mesh là phần dễ ra xấu nhất của cả loạt bài. Lần đầu mình viết, cái hộp thử nghiệm mọc đầy gai. Bài này đi qua đúng những lần sai đó.
Tải mã nguồn: bill-dissolve-urp.zip, cùng gói với phần 1 và phần 2.
Bước 1: cho vertex biết mép cắt ở đâu
Pixel biết mình sắp bị cắt nhờ con số t - threshold: bằng 0 ngay trên mép cắt, dương ở phần còn nguyên, âm ở phần đã tan. Vertex shader tính đúng con số đó, bằng đúng hàm DissolveTime của phần 1 và phần 2, rồi dựa vào nó để dời vị trí.
Shaders/Dissolve/BillDissolveForwardPass.hlsl
DissolveVaryings DissolveVertex(DissolveAttributes input)
{
// ...
float3 positionOS = DissolveDisplace(input.positionOS.xyz, input.normalOS, input.smoothNormalOS);
VertexPositionInputs positionInputs = GetVertexPositionInputs(positionOS);
// ...
output.restPositionOS = input.positionOS.xyz;
output.restNormalOS = input.normalOS;
return output;
}
Hai dòng cuối là thứ đã chuẩn bị từ phần 1: pixel vẫn đọc noise theo vị trí gốc của vertex, không phải vị trí sau khi dời. Nếu pixel đọc theo vị trí đã dời, thì vertex vừa phồng ra, noise tại chỗ đó đổi giá trị, mép cắt nhảy sang chỗ khác, vertex ở chỗ mới lại phồng, và mép cắt cứ đuổi theo chính nó. Đọc theo vị trí gốc thì hoa văn đứng yên trên bề mặt, chỉ hình khối là chuyển động.
DissolveDisplace cũng được gọi trong vertex shader của pass bóng, pass depth và pass viền (xem phần 1). Bỏ sót pass nào thì pass đó vẫn thấy hình khối cũ: bóng đổ không phồng, còn viền thì vẽ theo cái vỏ không còn nữa.
Bước 2: đẩy một chiều
Cách đầu tiên mình viết: vertex càng gần mép cắt thì bị đẩy ra càng xa, theo hướng normal. Gọi d là khoảng cách tới mép cắt, tính theo đơn vị của t:
half d = DissolveTime(positionOS, normalOS) - DissolveThreshold();
half w = 1.0h - saturate(d / _VertexBand);
w = w * w * (3.0h - 2.0h * w);
positionOS += normalOS * _PushDistance * w;
w bằng 1 ở mép cắt, giảm về 0 khi vertex nằm sâu trong phần còn nguyên quá _VertexBand. Dòng giữa là smoothstep viết tay, để chỗ bắt đầu phồng không bị gãy khúc.

Hộp bên trái là kết quả: mép cắt tua tủa mảnh vụn. Lỗi nằm ở phần đã tan. Với d âm, saturate kẹp về 0, nên w bằng 1: mọi vertex đã bị cắt vẫn bị đẩy ra hết cỡ. Những vertex đó không hiện, vì pixel của chúng đã bị clip. Nhưng tam giác nằm vắt qua mép cắt có một đỉnh bị đẩy xa, một đỉnh ở gần, nên phần còn hiện của nó bị kéo xiên thành một mảnh nhọn.
Bước 3: đẩy thành bướu
Muốn "tan đến đâu đẩy đến đó" thì chỗ phồng phải đi theo mép cắt: cao nhất ngay tại mép, hạ về 0 ở cả hai phía. Chỉ cần lấy trị tuyệt đối:
half w = 1.0h - saturate(abs(d) / max(_VertexBand, 1e-4h));
Vertex đã tan xa quá _VertexBand quay về đúng chỗ cũ, nên tam giác vắt qua mép cắt không còn bị kéo xiên.
Hộp ở giữa là kết quả: đỡ hơn, nhưng vẫn rách. Lần này lỗi không nằm ở đường cong mà ở mật độ. Hộp chia 24 ô mỗi cạnh, hai vertex cạnh nhau cách nhau khoảng 3 cm. Noise đọc ở mức mip 0 có chi tiết nhỏ hơn thế: hai vertex sát nhau có thể nhận hai giá trị d cách xa nhau, một cái bị đẩy 12 cm, cái bên cạnh không nhúc nhích. Mesh không thể hiện một chi tiết nhỏ hơn khoảng cách giữa các vertex của nó, và thứ gì nhỏ hơn thì thành gai.
Bước 4: vertex đọc noise mờ
Cách sửa là cho vertex đọc một bản noise đã mờ bớt, còn pixel vẫn đọc bản nét. Texture sẵn có các mức mip, mỗi mức là ảnh thu nhỏ một nửa của mức trước, tức là bản mờ dần của cùng một noise. Hàm đọc noise nhận mức mip làm tham số:
Shaders/Dissolve/BillDissolveCore.hlsl
half SampleDissolveNoise(float2 uv, half mip)
{
return SAMPLE_TEXTURE2D_LOD(_NoiseMap, sampler_NoiseMap, uv, mip).r;
}
DissolveNoise và DissolveTime truyền tham số này xuống, và DissolveDisplace gọi với _VertexNoiseMip:
half d = DissolveTime(positionOS, normalOS, _VertexNoiseMip) - DissolveThreshold();
Chọn mức mip bằng một phép tính nhỏ. Với _NoiseScale 2.2, một ô noise dài 1 / 2.2 = 0.45 m. Tile 128 pixel ở mip 0 thì mỗi pixel phủ 3.5 mm. Mỗi mức mip gấp đôi con số đó: mip 3 là 2.8 cm, xấp xỉ khoảng cách vertex, vẫn ra gai. Mip 5 là 11 cm, mỗi pixel noise phủ khoảng bốn vertex, và mép loe ra mượt như hộp bên phải. Mesh thưa hơn thì cần mức mip cao hơn.
Mép cắt thì vẫn nét, vì pixel vẫn đọc mip 0. Chỗ phồng do vertex quyết định chỉ còn là một đường bao mềm quanh mép cắt, không bám theo từng chi tiết nhỏ. Mắt người không nhận ra chỗ lệch đó, vì mép cắt với viền sáng mới là thứ thu hút ánh nhìn.
Có một giới hạn nên biết trước. Với dissolve chỉ dùng noise (không có hình dạng của phần 2), noise sau khi làm mờ gần như chỉ còn một màu xám trung bình, và cả bề mặt phồng lên cùng một lúc chứ không theo lỗ nào. Đẩy mesh chỉ đẹp khi có một hình dạng dẫn đường, như tan theo hướng hay từ một điểm, lúc đó noise mờ chỉ làm cho vành phồng lượn sóng.
Bước 5: đẩy theo normal nào
Bài toon shader đã gặp chuyện này với viền inverted hull: mesh có cạnh cứng thì mỗi đỉnh ở cạnh là hai, ba vertex chồng lên nhau, mỗi cái mang normal của mặt riêng. Đẩy theo normal đó thì các mặt tách khỏi nhau ở cạnh.

Cách sửa cũng giống bài toon: đẩy theo normal mượt đã bake vào UV3.
half3 direction = dot(smoothNormalOS, smoothNormalOS) > 0.01h ? smoothNormalOS : normalOS;
float3 offsetWS = TransformObjectToWorldDir(direction) * (_PushDistance * w);
positionOS += TransformWorldToObjectDir(offsetWS, false);
Mesh nào có UV3 (model import qua SmoothNormalBaker của bài toon) thì dùng normal mượt, không có thì dùng normal thường. smoothNormalOS là TEXCOORD3 trong struct đầu vào của mọi pass.
Hai dòng cuối đổi hướng sang world, nhân với khoảng cách tính bằng mét, rồi đổi ngược về object space mà không chuẩn hóa (tham số false). Nếu cộng thẳng direction * _PushDistance trong object space, vật scale 2 lần sẽ phồng gấp đôi, và mesh xuất theo centimet sẽ phồng gấp trăm lần.
Hộp bên phải là cái bẫy còn lại: khối Cube có sẵn của Unity chỉ có 24 vertex, nằm hết ở tám góc. Không có vertex nào ở giữa mặt để phồng. Muốn đẩy mesh thì mesh phải đủ dày ở những chỗ cần phồng. Với nhân vật thì thường đã đủ; với prop ít polygon thì phải chia thêm, hoặc chấp nhận chỉ cắt pixel.

Bước 6: kéo về một điểm
Kéo dùng lại gần như toàn bộ phần trên, chỉ khác hai chỗ. Thứ nhất, đường cong trở lại một chiều: phần đã tan phải bay hẳn về đích, không được quay về chỗ cũ. Thứ hai, thay vì cộng một khoảng theo normal, vertex được lerp về một điểm trong world:
half w = 1.0h - saturate(d / max(_VertexBand, 1e-4h));
w = w * w * (3.0h - 2.0h * w);
float3 positionWS = TransformObjectToWorld(positionOS);
positionWS = lerp(positionWS, _PullTarget.xyz, w * w);
positionOS = TransformWorldToObject(positionWS);
Cái mà ở bước 2 là lỗi (tam giác vắt qua mép cắt bị kéo xiên) giờ lại chính là hiệu ứng mình cần: những tam giác đó bị kéo dài thành các sợi chạy về đích, trông như vật bị hút vào. w * w làm vertex bám lại chỗ cũ lâu hơn một chút rồi mới lao đi, nên phần đang bị hút có dạng phễu chứ không phải một đường thẳng.
Để phía gần đích tan trước, material dùng hình dạng Sphere của phần 2, với tâm cầu đặt tại đích. Script DissolveShape đẩy luôn tâm cầu vào _PullTarget:
block.SetFloat(RadiusId, radius);
block.SetVector(PullTargetId, center);
Gán quả cầu phát sáng vào ô sphereCenter là xong. Đích di chuyển thì dòng vertex cũng uốn theo.
Kéo mesh có một cái bẫy mà cắt pixel không có: culling. Unity chỉ vẽ một vật khi hộp bao của nó nằm trong khung hình, và hộp bao đó tính theo mesh gốc. Vertex đã bị kéo lên tận quả cầu thì nằm ngoài hộp bao. Camera quay đi chỉ còn thấy quả cầu, thì cả con Burrow (kể cả dòng vertex đang bay vào cầu) bị bỏ qua không vẽ. Với SkinnedMeshRenderer, nới hộp bao ra:
var bounds = skinned.localBounds;
bounds.Expand(4f);
skinned.localBounds = bounds;
Với MeshRenderer thường thì đặt mesh.bounds lớn hơn, giống cách lưới nước ở bài stylized water nới hộp bao cho đỉnh sóng.
Bước 7: đừng phồng trước khi bắt đầu
Còn một chỗ sửa nhỏ nhưng dễ bỏ sót. Phần 1 cho ngưỡng bắt đầu từ -_EdgeWidth để Amount 0 không có viền sáng. Vertex có dải riêng là _VertexBand, thường rộng hơn viền. Nếu ngưỡng chỉ lùi theo viền, thì ở Amount 0 những vertex có t gần 0 đã nằm trong dải và bị đẩy, dù chưa có pixel nào bị cắt. Nên ngưỡng lùi theo dải nào rộng hơn:
half DissolveThreshold()
{
#if defined(_VERTEX_PUSH) || defined(_VERTEX_PULL)
half start = max(_EdgeWidth, _VertexBand);
#else
half start = _EdgeWidth;
#endif
return lerp(-start, 1.001h, _DissolveAmount);
}
Hàm này được gọi cả ở vertex lẫn pixel, nên keyword _VERTEX_ phải có mặt ở cả hai giai đoạn. Vì vậy mình khai báo bằng shader_feature_local chứ không phải shader_feature_local_vertex. Nếu dùng bản _vertex, pixel shader không thấy keyword, tính ra một ngưỡng khác với vertex, và chỗ phồng lệch khỏi mép cắt.
Tiếp theo
Phần 4 rời khỏi từng vật riêng lẻ: một mặt nạ dùng chung cho cả cảnh, ghép từ nhiều hình dạng, để làm transition, nhìn xuyên tường khi nhân vật bị che, và X-ray.
File trong bài
Mọi file nằm trong bill-dissolve-urp.zip. Các file liên quan tới phần này:
Material để thử kiểu đẩy: trên Dissolve_Demo, chọn Shape: Direction, Vertex Motion: Push, Vertex Band 0.2, Push Distance 0.1, Vertex Noise Mip 5, rồi gắn DissolveShape lên vật.
Tham khảo
- Minions Art: Vertical Dissolve / Teleport, ý tưởng đẩy vertex trong một dải mềm quanh mép tan.
- Ronja: Vertex Displacement, nền tảng về dời vertex trong shader.
- Unity: SkinnedMeshRenderer.localBounds.
Burrow là asset từ Asset Store (Monsters Ultimate Pack), chỉ dùng để minh họa và không có trong gói tải về.
