Overview
A parametric procedural crate HDA built in Houdini. Adjusting presets produces crates with different sizes, trims and accessories; the assets are batch-exported through a PDG workflow with their textures. In Unreal Engine those textures drive procedural materials that, combined with vertex colors, allow crates of different materials and sizes to be produced without painting separate UV textures for each model.
HDA parameters
The body, lid, base, metal frame, handles and light strips are each parameter-driven and can be combined into many crate types.
Node structure
The network is grouped by part: base, lid, top metal, handles, lights and body are built separately and merged at the end.
VEX tools for modeling
Some operations are cumbersome or expensive with SOPs alone, so three HDA tools were written in VEX: point filtering, copying points across a face, and moving selected primitives.
Plane Modifier
Finds the geometry’s bounding box and lowest points, then moves points along X, Y and Z according to user parameters. Point normals decide which points may move, the lowest group is handled separately, and distance, direction and range are all parameter-driven.
Filter Points with Bounding Box
Filters a point cloud with a bounding box and writes the result to groups. The script reads the box size and user parameters, then either marks points that meet the condition or counts occurrences above or below a threshold, with an option to invert the selection.
Make Copy Points from Prim
Generates copy points from a given primitive. It computes the start and end midpoints, derives the point count from segment length and style, and can connect the points into a polyline. Points can be offset by direction and amount, including in reverse.
PDG asset management and batch export
After assembly, each part is given a material name according to the texturing plan, named modules are merged with Booleans, and groups are reorganized. To export variants, several crate HDAs are duplicated with different parameters and exported in batch with a Python script. Parts are then assigned vertex colors by material, high-poly versions are generated to the polygon budget and reduced while transferring UVs, and the final models are exported with Mask, Position, ID and Object Normal maps.
Procedural materials in Unreal Engine
Maps from Houdini are imported into UE5 as virtual textures and used as vertex color. Procedural materials are combined with the vertex colors, which separate the model into regions so each area gets its own material without per-model UV textures.
Appendix: VEX source · 16 snippets
set_pts_pos
Uses position, normals and the lowest point group to distinguish lower and upper points, applying the tool’s translation and scaling parameters to the cutting planes.
// 定义变量
int mx = chi("mv_x");
float x_dist = chf("x_mv_dist");
int my = chi("mv_y");
float y_dist = chf("y_mv_dist");
int mz = chi("mv_z");
float z_dist = chf("z_mv_dist");
int mt = chi("mv_y_max");
float mt_dist = chf("mt_amount");
int mb = chi("mv_y_min");
float mb_dist = chf("mb_amount");
int st = chi("sc_y_max");
float st_dist = chf("sc_y_max_amount");
int sb = chi("sc_y_min");
float sb_dist = chf("sc_y_min_amount");
// 定义 SetValue 函数
float SetValue(float component, dist)
{
float value = component;
if (value > 0)
value += dist;
else
value -= dist;
return value;
}
// 定义方向向量
vector x = set(1, 0, 0);
vector y = set(0, 1, 0);
vector z = set(0, 0, 1);
// 获取当前点是否属于 'lowest' 组
int isLowest = i@group_lowest;
if (mx)
@P.x = SetValue(@P.x, x_dist);
if (mz)
@P.z = SetValue(@P.z, z_dist);
if (my)
{
if (dot(@N, y) > 0.99 || dot(@N, -y) > 0.99)
{
// 如果法线接近 y 或 -y 方向,直接沿法线方向移动
@P += @N * y_dist;
}
else
{
// 根据是否属于 'lowest' 组来决定 y 方向的移动
if (isLowest)
@P.y = SetValue(@P.y, -y_dist);
else
@P.y = SetValue(@P.y, y_dist);
}
}
if (mt && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (!isLowest)
@P.y += mt_dist;
}
if (mb && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (isLowest)
@P.y -= mb_dist;
}
// 处理 X 轴缩放
if (st && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (!isLowest)
{
if (@P.z > 0)
@P.z += st_dist;
else
@P.z -= st_dist;
}
}
if (sb && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (isLowest)
{
if (@P.z > 0)
@P.z += sb_dist;
else
@P.z -= sb_dist;
}
}
// 处理 Z 轴缩放
if (st && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (!isLowest)
{
if (@P.x > 0)
@P.x += st_dist;
else
@P.x -= st_dist;
}
}
if (sb && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (isLowest)
{
if (@P.x > 0)
@P.x += sb_dist;
else
@P.x -= sb_dist;
}
}set_pts_pos
// 定义变量
int mx = chi("mv_x");
float x_dist = chf("x_mv_dist");
int my = chi("mv_y");
float y_dist = chf("y_mv_dist");
int mz = chi("mv_z");
float z_dist = chf("z_mv_dist");
int mt = chi("mv_y_max");
float mt_dist = chf("mt_amount");
int mb = chi("mv_y_min");
float mb_dist = chf("mb_amount");
int st = chi("sc_y_max");
float st_dist = chf("sc_y_max_amount");
int sb = chi("sc_y_min");
float sb_dist = chf("sc_y_min_amount");
// 定义 SetValue 函数
float SetValue(float component, dist)
{
float value = component;
if (value > 0)
value += dist;
else
value -= dist;
return value;
}
// 定义方向向量
vector x = set(1, 0, 0);
vector y = set(0, 1, 0);
vector z = set(0, 0, 1);
// 获取当前点是否属于 'lowest' 组
int isLowest = i@group_lowest;
if (mx)
@P.x = SetValue(@P.x, x_dist);
if (mz)
@P.z = SetValue(@P.z, z_dist);
if (my)
{
if (dot(@N, y) > 0.99 || dot(@N, -y) > 0.99)
{
// 如果法线接近 y 或 -y 方向,直接沿法线方向移动
@P += @N * y_dist;
}
else
{
// 根据是否属于 'lowest' 组来决定 y 方向的移动
if (isLowest)
@P.y = SetValue(@P.y, -y_dist);
else
@P.y = SetValue(@P.y, y_dist);
}
}
if (mt && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (!isLowest)
@P.y += mt_dist;
}
if (mb && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (isLowest)
@P.y -= mb_dist;
}
// 处理 X 轴缩放
if (st && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (!isLowest)
{
if (@P.z > 0)
@P.z += st_dist;
else
@P.z -= st_dist;
}
}
if (sb && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (isLowest)
{
if (@P.z > 0)
@P.z += sb_dist;
else
@P.z -= sb_dist;
}
}
// 处理 Z 轴缩放
if (st && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (!isLowest)
{
if (@P.x > 0)
@P.x += st_dist;
else
@P.x -= st_dist;
}
}
if (sb && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (isLowest)
{
if (@P.x > 0)
@P.x += sb_dist;
else
@P.x -= sb_dist;
}
}set_pts_pos
// 定义变量
int mx = chi("mv_x");
float x_dist = chf("x_mv_dist");
int my = chi("mv_y");
float y_dist = chf("y_mv_dist");
int mz = chi("mv_z");
float z_dist = chf("z_mv_dist");
int mt = chi("mv_y_max");
float mt_dist = chf("mt_amount");
int mb = chi("mv_y_min");
float mb_dist = chf("mb_amount");
int st = chi("sc_y_max");
float st_dist = chf("sc_y_max_amount");
int sb = chi("sc_y_min");
float sb_dist = chf("sc_y_min_amount");
// 定义 SetValue 函数
float SetValue(float component, dist)
{
float value = component;
if (value > 0)
value += dist;
else
value -= dist;
return value;
}
// 定义方向向量
vector x = set(1, 0, 0);
vector y = set(0, 1, 0);
vector z = set(0, 0, 1);
// 获取当前点是否属于 'lowest' 组
int isLowest = i@group_lowest;
if (mx)
@P.x = SetValue(@P.x, x_dist);
if (mz)
@P.z = SetValue(@P.z, z_dist);
if (my)
{
if (dot(@N, y) > 0.99 || dot(@N, -y) > 0.99)
{
// 如果法线接近 y 或 -y 方向,直接沿法线方向移动
@P += @N * y_dist;
}
else
{
// 根据是否属于 'lowest' 组来决定 y 方向的移动
if (isLowest)
@P.y = SetValue(@P.y, -y_dist);
else
@P.y = SetValue(@P.y, y_dist);
}
}
if (mt && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (!isLowest)
@P.y += mt_dist;
}
if (mb && (dot(@N, y) < 0.99 && dot(@N, -y) < 0.99))
{
if (isLowest)
@P.y -= mb_dist;
}
// 处理 X 轴缩放
if (st && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (!isLowest)
{
if (@P.z > 0)
@P.z += st_dist;
else
@P.z -= st_dist;
}
}
if (sb && (dot(@N, x) > 0.99 || dot(@N, -x) > 0.99))
{
if (isLowest)
{
if (@P.z > 0)
@P.z += sb_dist;
else
@P.z -= sb_dist;
}
}
// 处理 Z 轴缩放
if (st && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (!isLowest)
{
if (@P.x > 0)
@P.x += st_dist;
else
@P.x -= st_dist;
}
}
if (sb && (dot(@N, z) > 0.99 || dot(@N, -z) > 0.99))
{
if (isLowest)
{
if (@P.x > 0)
@P.x += sb_dist;
else
@P.x -= sb_dist;
}
}attribwrangle2
int x_dir = chi("mv_x_direction");
int y_dir = chi("mv_y_direction");
int z_dir = chi("mv_z_direction");
int reverse = chi("reverse_normal_direction");
float amountX = chf("x_amount");
float amountY = chf("y_amount");
float amountZ = chf("z_amount");
vector x = {1, 0, 0};
vector y = {0, 1, 0};
vector z = {0, 0, 1};
if (reverse == 1)
{
x *= -1;
y *= -1;
z *= -1;
}
if (x_dir) @P += x * amountX;
if (y_dir) @P += y * amountY;
if (z_dir) @P += z * amountZ;
copy_points
// 获取几何体或节点1中的顶点位置并创建中间点
vector pos0 = point(1, "P", vertexpoint(1, 0));
vector pos1 = point(1, "P", vertexpoint(1, 1));
vector pos2 = point(1, "P", vertexpoint(1, 2));
vector pos3 = point(1, "P", vertexpoint(1, 3));
int dir = chi("change_direction");
vector p0, p1;
// 根据方向参数决定如何计算中间点
if (dir == 1)
{
p0 = (pos0 + pos1) * 0.5;
p1 = (pos2 + pos3) * 0.5;
}
else
{
p0 = (pos1 + pos2) * 0.5;
p1 = (pos0 + pos3) * 0.5;
}
// 起始点之间的距离
float pt_dist = distance(p0,p1);
//用户输入的线段resample长度
float usr_dif_len = chf("length");
//点的间隔距离
float segment_len =0;
// 点的总数
int total_pts=(int)ceil(pt_dist /usr_dif_len);
// 点移动的方向
vector line_dir =normalize(p1-p0);
segment_len=pt_dist/(total_pts-1);for(int i=0;i<total_pts; i++)
if(i !=0&& i<total_pts -1)
addpoint(0, p0+segment_len * i * line_dir);attribwrangle1
@N = {0, 0, -1};
f@pscale = chf("size_scale");attribwrangle2
removeprim(0, 0, 1);
@N = {1, 0, 0};
@pscale = chf("size_scale");attribwrangle3
i@count = chi("brvel_Divisions");
if (@count > 0 && @count % 2 != 0)
i@count += 1;attribwrangle4
@Cd = 0;
@Cd.r = point(1, "convexity", @ptnum);
@Cd.g = point(1, "concavity", @ptnum);
@Cd.b = point(2, "mask", @ptnum);set_lowest_group
// 获取当前几何体的最小包围盒坐标
vector bbmin = getbbox_min(0);
// 获取最低的 y 坐标
float lowest_y = bbmin.y;
// 创建一个数组来存储最低点的索引
int lowest[] = {};
// 遍历所有点
for (int i = 0; i < @numpt; i++) {
// 获取点的位置
vector pos = point(0, "P", i);
// 检查点的 y 坐标是否等于最低 y 坐标
if (pos.y == lowest_y) {
// 将点的索引添加到数组中
append(lowest, i);
}
}
// 如果最低点数组的长度为2,则将这些点加入 "lowest" 组
if (len(lowest) == 2) {
foreach (int pt; lowest) {
setpointgroup(0, "lowest", pt, 1, "set");
}
}filter_points1
vector bbmax = getbbox_max(0);
vector bbmin = getbbox_min(0);
int methold = chi("filter_method");
int max_x = chi("x_max");
int max_y = chi("y_max");
int max_z = chi("z_max");
int min_x = chi("x_min");
int min_y = chi("y_min");
int min_z = chi("z_min");
int all_pts[] = {};
// 遍历所有顶点,根据条件收集符合要求的顶点编号
for (int i = 0; i < npoints(0); i++)
{
vector pos = point(0, "P", i);
if (max_x && pos.x == bbmax.x)
append(all_pts, i);
if (max_y && pos.y == bbmax.y)
append(all_pts, i);
if (max_z && pos.z == bbmax.z)
append(all_pts, i);
if (min_x && pos.x == bbmin.x)
append(all_pts, i);
if (min_y && pos.y == bbmin.y)
append(all_pts, i);
if (min_z && pos.z == bbmin.z)
append(all_pts, i);
}
// 根据选择方法对顶点进行标记
if (methold == 1)
{
// 标记出现次数大于等于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count >= 2)
setpointattrib(0, "selected", i, 1, "set");
}
}
else if (methold == 0)
{
// 直接标记所有收集到的顶点
foreach (int pt; all_pts)
{
setpointattrib(0, "selected", pt, 1, "set");
}
}
else if (methold == 2)
{
// 标记出现次数小于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count = 1)
setpointattrib(0, "selected", i, 1, "set");
}
}
filter_points
vector bbmax = getbbox_max(0);
vector bbmin = getbbox_min(0);
int methold = chi("filter_method");
int reverse = chi("reserve_selection");
string groupName = chs("group_name");
int max_x = chi("x_max");
int max_y = chi("y_max");
int max_z = chi("z_max");
int min_x = chi("x_min");
int min_y = chi("y_min");
int min_z = chi("z_min");
int all_pts[] = {};
int makeGroup[] = {};
// 定义函数:根据类型决定是否添加顶点到数组
function void AddToArray(int Array[]; int pt; int type)
{
if (type == 0 && find(Array, pt) < 0)
append(Array, pt);
else
append(Array, pt);
}
// 遍历所有顶点,根据条件调用函数添加顶点编号到数组
for (int i = 0; i < npoints(0); i++)
{
vector pos = point(0, "P", i);
if (max_x && pos.x == bbmax.x)
AddToArray(all_pts, i, methold);
if (max_y && pos.y == bbmax.y)
AddToArray(all_pts, i, methold);
if (max_z && pos.z == bbmax.z)
AddToArray(all_pts, i, methold);
if (min_x && pos.x == bbmin.x)
AddToArray(all_pts, i, methold);
if (min_y && pos.y == bbmin.y)
AddToArray(all_pts, i, methold);
if (min_z && pos.z == bbmin.z)
AddToArray(all_pts, i, methold);
}
// 根据选择方法对顶点进行标记
if (methold == 0)
{
append(makeGroup, all_pts);
}
else if (methold == 1)
{
// 标记出现次数大于等于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count >= 2)
append(makeGroup, i);
}
}
else if (methold == 2)
{
// 标记出现次数小于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count < 2)
append(makeGroup, i);
}
}
// 根据 reverse 变量设置点组
if (!reverse)
{
foreach (int p; makeGroup)
setpointgroup(0, groupName, p, 1, "set");
}
else
{
for (int p = 0; p < npoints(0); p++)
{
if (find(makeGroup, p) < 0)
setpointgroup(0, groupName, p, 1, "set");
}
}
set_lowest_group
// 获取当前几何体的最小包围盒坐标
vector bbmin = getbbox_min(0);
// 获取最低的 y 坐标
float lowest_y = bbmin.y;
// 创建一个数组来存储最低点的索引
int lowest[] = {};
// 遍历所有点
for (int i = 0; i < @numpt; i++) {
// 获取点的位置
vector pos = point(0, "P", i);
// 检查点的 y 坐标是否等于最低 y 坐标
if (pos.y == lowest_y) {
// 将点的索引添加到数组中
append(lowest, i);
}
}
// 如果最低点数组的长度为2,则将这些点加入 "lowest" 组
if (len(lowest) == 2) {
foreach (int pt; lowest) {
setpointgroup(0, "lowest", pt, 1, "set");
}
}attribwrangle1
vector bbmax = getbbox_max(0);
if(@P.y == bbmax.y) i@group_topBevel = 1;filter_points
vector bbmax = getbbox_max(0);
vector bbmin = getbbox_min(0);
int methold = chi("filter_method");
int reverse = chi("reserve_selection");
string groupName = chs("group_name");
int max_x = chi("x_max");
int max_y = chi("y_max");
int max_z = chi("z_max");
int min_x = chi("x_min");
int min_y = chi("y_min");
int min_z = chi("z_min");
int all_pts[] = {};
int makeGroup[] = {};
// 定义函数:根据类型决定是否添加顶点到数组
function void AddToArray(int Array[]; int pt; int type)
{
if (type == 0 && find(Array, pt) < 0)
append(Array, pt);
else
append(Array, pt);
}
// 遍历所有顶点,根据条件调用函数添加顶点编号到数组
for (int i = 0; i < npoints(0); i++)
{
vector pos = point(0, "P", i);
if (max_x && pos.x == bbmax.x)
AddToArray(all_pts, i, methold);
if (max_y && pos.y == bbmax.y)
AddToArray(all_pts, i, methold);
if (max_z && pos.z == bbmax.z)
AddToArray(all_pts, i, methold);
if (min_x && pos.x == bbmin.x)
AddToArray(all_pts, i, methold);
if (min_y && pos.y == bbmin.y)
AddToArray(all_pts, i, methold);
if (min_z && pos.z == bbmin.z)
AddToArray(all_pts, i, methold);
}
// 根据选择方法对顶点进行标记
if (methold == 0)
{
append(makeGroup, all_pts);
}
else if (methold == 1)
{
// 标记出现次数大于等于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count >= 2)
append(makeGroup, i);
}
}
else if (methold == 2)
{
// 标记出现次数小于2的顶点
for (int i = 0; i < npoints(0); i++)
{
int count = 0;
foreach (int pt; all_pts)
{
if (i == pt)
count++;
}
if (count < 2)
append(makeGroup, i);
}
}
// 根据 reverse 变量设置点组
if (!reverse)
{
foreach (int p; makeGroup)
setpointgroup(0, groupName, p, 1, "set");
}
else
{
for (int p = 0; p < npoints(0); p++)
{
if (find(makeGroup, p) < 0)
setpointgroup(0, groupName, p, 1, "set");
}
}
set_lowest_group
// 获取当前几何体的最小包围盒坐标
vector bbmin = getbbox_min(0);
// 获取最低的 y 坐标
float lowest_y = bbmin.y;
// 创建一个数组来存储最低点的索引
int lowest[] = {};
// 遍历所有点
for (int i = 0; i < @numpt; i++) {
// 获取点的位置
vector pos = point(0, "P", i);
// 检查点的 y 坐标是否等于最低 y 坐标
if (pos.y == lowest_y) {
// 将点的索引添加到数组中
append(lowest, i);
}
}
// 如果最低点数组的长度为2,则将这些点加入 "lowest" 组
if (len(lowest) == 2) {
foreach (int pt; lowest) {
setpointgroup(0, "lowest", pt, 1, "set");
}
}Demonstration video

Technical content adapted from the author’s portfolio and project files.