Ben Traje
← Back to input-output

Exporting Grouped Houdini Point Data to Unity via JSON (Without Houdini Engine)

02 Oct 26 (Today)

Using Houdini as an offline layout engine avoids the overhead of runtime Houdini Engine sessions while allowing you to scatter complex environments with LODs and prefabs.

Rather than exporting a single flat array or baking static geometry, this workflow groups point data dynamically by an attribute (like name) in Python, exports it into clean JSON, and uses a native-friendly Unity importer to instantiate matching prefabs automatically.

Houdini Setup: Grouped Python Export

In Houdini, assign a name string attribute to your scattered points (e.g., "Tree_Oak", "Rock_Large", "Debris"). A Python SOP then groups the transforms by this identifier and converts coordinates to match Unity.

Coordinate Conversion

  • Position: Houdini uses right-handed ($Y$-up, $Z$-out), while Unity uses left-handed ($Y$-up, $Z$-forward). Invert the $Z$ position (-pos[2]).
  • Rotation: Invert the $Z$ and $W$ components of the orient quaternion (-orient[2], -orient[3]).

Wire your point geometry into a Python SOP and paste the following script:

import json
import os
import hou

node = hou.pwd()
geo = node.geometry()

# Root container wrapping grouped data
# Structure: {"groups": [ {"name": "Tree_Oak", "points": [...]}, ... ]}
grouped_dict = {}

has_name = geo.findPointAttrib("name") is not None
has_orient = geo.findPointAttrib("orient") is not None
has_pscale = geo.findPointAttrib("pscale") is not None

for pt in geo.points():
    group_name = pt.stringAttribValue("name") if has_name else "Default"
    
    if group_name not in grouped_dict:
        grouped_dict[group_name] = []

    pos = pt.position()
    
    # 1. Position (Flip Z for Unity)
    pt_data = {
        "x": pos[0],
        "y": pos[1],
        "z": -pos[2]
    }
    
    # 2. Scale
    pt_data["pscale"] = pt.attribValue("pscale") if has_pscale else 1.0
    
    # 3. Rotation (Flip Z and W for Unity)
    if has_orient:
        orient = pt.attribValue("orient")
        pt_data["qx"] = orient[0]
        pt_data["qy"] = orient[1]
        pt_data["qz"] = -orient[2]
        pt_data["qw"] = -orient[3]
    else:
        pt_data["qx"], pt_data["qy"], pt_data["qz"], pt_data["qw"] = 0.0, 0.0, 0.0, 1.0

    grouped_dict[group_name].append(pt_data)

# Pack into a structure Unity's native JsonUtility can read without third-party plugins
export_data = {
    "groups": [
        {"name": key, "points": points}
        for key, points in grouped_dict.items()
    ]
}

# Target file path inside your Unity project
file_path = "C:/YourUnityProject/Assets/Data/houdini_layout.json"
directory = os.path.dirname(file_path)

if not os.path.exists(directory):
    os.makedirs(directory)

with open(file_path, 'w') as f:
    json.dump(export_data, f, indent=4)

The JSON Structure

By nesting the groups inside a top-level list, the JSON remains 100% compatible with Unity's fast, built-in JsonUtility without needing external libraries like Newtonsoft.Json:

{
    "groups": [
        {
            "name": "Tree_Oak",
            "points": [
                {
                    "x": 1.25,
                    "y": 0.0,
                    "z": -5.42,
                    "pscale": 1.2,
                    "qx": 0.0,
                    "qy": 0.38,
                    "qz": -0.0,
                    "qw": -0.92
                }
            ]
        },
        {
            "name": "Rock_Large",
            "points": [
                {
                    "x": 8.5,
                    "y": 1.0,
                    "z": 3.2,
                    "pscale": 0.8,
                    "qx": 0.0,
                    "qy": 0.0,
                    "qz": 0.0,
                    "qw": 1.0
                }
            ]
        }
    ]
}

Unity Importer & Instancer (C#)

Save this script as HoudiniMultiInstancer.cs and attach it to an empty GameObject in Unity.

It maps group names directly to assigned prefabs in the Inspector, supports baking directly in Edit Mode via context menus, and correctly cleans up spawned instances.

using UnityEngine;
using System.IO;
using System.Collections.Generic;

[System.Serializable]
public class PointData
{
    public float x, y, z;
    public float pscale;
    public float qx, qy, qz, qw;
}

[System.Serializable]
public class PointGroup
{
    public string name;
    public PointData[] points;
}

[System.Serializable]
public class LayoutPayload
{
    public PointGroup[] groups;
}

public class HoudiniMultiInstancer : MonoBehaviour
{
    [System.Serializable]
    public struct PrefabMapping
    {
        [Tooltip("Matches the 'name' attribute from Houdini (e.g. Tree_Oak)")]
        public string attributeName;
        public GameObject prefab;
    }

    [Header("File Settings")]
    [Tooltip("Path relative to Assets/ folder, e.g. Data/houdini_layout.json")]
    public string jsonRelativePath = "Data/houdini_layout.json";

    [Header("Prefab Mapping")]
    public List<PrefabMapping> prefabMappings = new List<PrefabMapping>();

    [Header("Runtime")]
    public bool spawnOnStart = false;

    void Start()
    {
        if (spawnOnStart)
        {
            GenerateInstances();
        }
    }

    [ContextMenu("Generate Instances")]
    public void GenerateInstances()
    {
        ClearInstances();

        string fullPath = Path.Combine(Application.dataPath, jsonRelativePath);

        if (!File.Exists(fullPath))
        {
            Debug.LogError($"[HoudiniInstancer] JSON not found at: {fullPath}");
            return;
        }

        // Build fast lookup dictionary for prefabs
        Dictionary<string, GameObject> prefabMap = new Dictionary<string, GameObject>();
        foreach (var mapping in prefabMappings)
        {
            if (!string.IsNullOrEmpty(mapping.attributeName) && mapping.prefab != null)
            {
                prefabMap[mapping.attributeName] = mapping.prefab;
            }
        }

        string rawJson = File.ReadAllText(fullPath);
        LayoutPayload payload = JsonUtility.FromJson<LayoutPayload>(rawJson);

        if (payload?.groups == null)
        {
            Debug.LogError("[HoudiniInstancer] Failed to parse JSON or no groups found.");
            return;
        }

        int totalInstantiated = 0;

        foreach (PointGroup group in payload.groups)
        {
            if (!prefabMap.TryGetValue(group.name, out GameObject targetPrefab))
            {
                Debug.LogWarning($"[HoudiniInstancer] No prefab mapped for attribute name '{group.name}'. Skipping group.");
                continue;
            }

            // Create a parent GameObject per group to keep hierarchy organized
            Transform groupRoot = new GameObject($"Group_{group.name}").transform;
            groupRoot.SetParent(transform, false);

            foreach (PointData pt in group.points)
            {
                Vector3 position = new Vector3(pt.x, pt.y, pt.z);
                Quaternion rotation = new Quaternion(pt.qx, pt.qy, pt.qz, pt.qw);

                GameObject instance = Instantiate(targetPrefab, position, rotation, groupRoot);
                instance.transform.localScale = Vector3.one * (pt.pscale > 0f ? pt.pscale : 1f);
                totalInstantiated++;
            }
        }

        Debug.Log($"[HoudiniInstancer] Successfully spawned {totalInstantiated} instances across {payload.groups.Length} groups.");
    }

    [ContextMenu("Clear Instances")]
    public void ClearInstances()
    {
        for (int i = transform.childCount - 1; i >= 0; i--)
        {
            Transform child = transform.GetChild(i);
            if (Application.isPlaying)
            {
                Destroy(child.gameObject);
            }
            else
            {
                DestroyImmediate(child.gameObject);
            }
        }
    }
}

Workflow in Action

  1. In Houdini: Scatter your points, use an Attribute Wrangle or Enumerate SOP to set s@name (e.g., s@name = "Tree_Oak";), and trigger the Python SOP.
  2. In Unity: Create an empty GameObject, add HoudiniMultiInstancer, set the file path, and add your mapping entries matching the name attributes.
  3. Right-click the component in the Inspector and select Generate Instances to bake directly into your scene or leave spawnOnStart checked for runtime population.