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.

Scene renders.

HDA parameters

The body, lid, base, metal frame, handles and light strips are each parameter-driven and can be combined into many crate types.

Crates generated from different parameter sets.

Node structure

The network is grouped by part: base, lid, top metal, handles, lights and body are built separately and merged at the end.

Houdini network overview.
Nodes and results for the base, lid, top metal and handles.
Nodes and results for the lights and body.

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.

Nodes and usage of the three VEX tools.

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.

Plane Modifier nodes and script.

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.

Filter Points with Bounding Box nodes and script.

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.

Make Copy Points from Prim nodes and script.

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.

Batch export and Python script.
Vertex-color classification and map export.

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.

Mask virtual texture, parent material and child material functions.
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.

set_pts_pos.parmVEX
// 定义变量
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
set_pts_pos.parmVEX
// 定义变量
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
set_pts_pos.parmVEX
// 定义变量
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
attribwrangle2.parmVEX
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
copy_points.parmVEX
// 获取几何体或节点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
attribwrangle1.parmVEX
@N = {0, 0, -1};
f@pscale = chf("size_scale");
attribwrangle2
attribwrangle2.parmVEX
removeprim(0, 0, 1);
@N = {1, 0, 0};
@pscale = chf("size_scale");
attribwrangle3
attribwrangle3.parmVEX
i@count = chi("brvel_Divisions");
if (@count > 0 && @count % 2 != 0)
   i@count += 1;
attribwrangle4
attribwrangle4.parmVEX
@Cd = 0;
@Cd.r = point(1, "convexity", @ptnum);
@Cd.g = point(1, "concavity", @ptnum);
@Cd.b = point(2, "mask", @ptnum);
set_lowest_group
set_lowest_group.parmVEX
// 获取当前几何体的最小包围盒坐标
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
filter_points1.parmVEX
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
filter_points.parmVEX
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
set_lowest_group.parmVEX
// 获取当前几何体的最小包围盒坐标
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
attribwrangle1.parmVEX
vector bbmax = getbbox_max(0);
if(@P.y == bbmax.y) i@group_topBevel = 1;
filter_points
filter_points.parmVEX
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
set_lowest_group.parmVEX
// 获取当前几何体的最小包围盒坐标
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

Procedural art reel

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

Next projectProcedural roads in Unreal