11 Jun 2025
I’ve long had the dream of creating high resolution chains on characters with raymarching. The problem is that Unity’s object transform is based on the character’s hip bone, so making raymarched geometry “stick” to characters is impossible. I believe that I’ve finally solved this.
TLDR:
The core idea is to make it possible for each fragment of a material to learn an origin point’s location and orientation. If you can recover an origin point and a rotation, then you can raymarch inside that coordinate system, then translate back to object coordinates at the end.
For each submesh* in a mesh, I bake an origin point and an orientation.
* A submesh is just a set of vertices connected by edges. A mesh might contain many unconnected submeshes. For example, in blender, you can combine two objects with ctrl+J. I call those two combined but unconnected things submeshes.
The orientation of the submesh is derived from the face normals. I sort the faces in the submesh by their area. The largest area face is used as the first basis vector of our rotated coordinate system. Then I get the next face which is sufficiently orthogonal to the first basis vector (absolute value of dot product is > some epsilon). I orthogonalize those two basis vectors with graham-schmidt, then generate the third with a cross product. I ensure right-handedness by checking that the determinant is positive, then convert to a quaternion. I then store that quaternion in 2 UV channels.
The rotation quaternion is recovered on the GPU as follows:
(v2f i, float2 uv_channels) {
float4 GetRotation;
float4 quat.xy = get_uv_by_channel(i, uv_channels.x);
quat.zw = get_uv_by_channel(i, uv_channels.y);
quatreturn quat;
}
...
(v2f i) {
RayMarcherOutput MyRayMarcher...
= float2(1, 2);
float2 uv_channels = GetRotation(i, uv_channels);
float4 quat = float4(-quat.xyz, quat.w);
float4 iquat }
It’s worth lingering here for a second. Each submesh is conceptualized as a rotated bounding box. We just deduced an orthonormal basis for that rotated coordinate system. That means that the artist can rotate their bounding boxes however they want in Blender, and the plugin will automatically work out how to orient things. You can arbitrarily move and rotate your bounding boxes and it Just Works.
The origin point is simply the average of all the vertex locations. I encode it as a vector from each vertex to that location, and stuff it into vertex colors. Since vertex colors can only encode numbers in the range [0, 1], I use the alpha channel to scale the length of each vertex.
I made two non obvious decisions in the way I bake the vertex offsets:
The offsets are encoded in terms of the rotated coordinate system. This saves one quaternion rotation in the shader.
The offsets are scaled according to the L-infinity norm (Manhattan distance) rather than the standard L2 norm (Euclidian distance). This lets the artist think in terms of the bounding box dimensions rather than the square root of the sum of squares of the box’s dimensions. Like if your box is 1x0.6x0.2, then you can just raymarch a primitive with those dimensions and your
The origin point is recovered on the GPU as follows:
(v2f i) {
float3 GetFragToOriginreturn (i.color * 2.0f - 1.0f) / i.color.a;
}
(v2f i) {
RayMarcherOutput MyRayMarcher...
= GetFragToOrigin(i);
float3 frag_to_origin }
With those pieces in place, the raymarcher is pretty standard, but some care has to be taken when getting into and out of the coordinate system. Here’s a complete example in HLSL:
(v2f i) {
RayMarcherOutput MyRayMarcher= mul(unity_WorldToObject,
float3 obj_space_camera_pos (_WorldSpaceCameraPos, 1.0));
float4= GetFragToOrigin(i);
float3 frag_to_origin
= float2(1, 2);
float2 uv_channels = GetRotation(i, uv_channels);
float4 quat = float4(-quat.xyz, quat.w);
float4 iquat
// ro is already expressed in terms of rotated basis vectors, so we don't
// have to rotate it again.
= -frag_to_origin;
float3 ro = normalize(i.objPos - obj_space_camera_pos);
float3 rd = rotate_vector(rd, iquat);
rd
float d;
float d_acc = 0;
const float epsilon = 1e-3f;
const float max_d = 1;
[loop]
for (uint ii; ii < CUSTOM30_MAX_STEPS; ++ii) {
= ro + rd * d_acc;
float3 p = map(p);
d += d;
d_acc if (d < epsilon) break;
if (d_acc > max_d) break;
}
(epsilon - d);
clip
= ro + rd * d_acc;
float3 localHit = rotate_vector(localHit, quat);
float3 objHit = rotate_vector(frag_to_origin, quat);
float3 objCenterOffset
;
RayMarcherOutput o.objPos = objHit + (i.objPos + objCenterOffset);
o= UnityObjectToClipPos(o.objPos);
float4 clipPos .depth = clipPos.z / clipPos.w;
o
// Calculate normal in rotated space using standard raymarcher gradient
// technique
= calc_normal(localHit);
float3 sdfNormal = rotate_vector(sdfNormal, quat);
float3 objNormal .normal = UnityObjectToWorldNormal(objNormal);
o
return o;
}
This technique is extremely scalable. I have a world with 16,000 bounding boxes that runs at ~800 microseconds/frame without volumetrics.
You can have overlapping raymarched geometry without paying the usual 8x slowdown of domain repetition.