Object Attributes (Pivot Painter) - GhislainGir/BlenderGameToolsDoc GitHub Wiki

This tool was originally intended as a simple imitation of existing Pivot Painter implementations, but it was reimagined and built from the ground up. This fresh approach opens the door to new workflows and use cases, which led to its rebranding as 'Object Attributes'.

Pivot Painter was initially a 3ds Max script released by Epic Games and created by Jonathan Linquist, offering limited functionality. It later evolved into version 2.0, which introduced much more exciting capabilities and gained widespread attention across the video-game industry, partly due to its innovative data-structure navigation and bit-packing techniques.

Theory

The algorithm begins by assigning a unique integer to each element—or object—incrementally while traversing the hierarchy. The root element (for example, a tree trunk) is usually assigned index 0, its first child (a branch) gets index 1, that child’s first child (a leaf) gets index 2, the second child (another leaf) gets index 3, and so on.

pp_elem_index

The end goal is to store each element’s attributes—such as position or axis—in a specific texel of an RGB texture, while the alpha channel is typically used to store the element’s parent index.

The total number of elements to bake determines the texture resolution. For instance, 9,873 elements could fit into a texture as small as 100×100 pixels. Index 234 would correspond to the 35th texel on the third row. The first row of pixels holds data for elements 0–99, the second row for 100–199, the third row for 200–299, and so on.

pp_index

Once each element has been assigned a unique index, that index is used to assign a specific texel coordinate to all of its vertices' UVs (typically using a second UV map to preserve the mesh’s original UVs). Because the attribute is baked per element, all vertices of that element must share the same UV coordinate, pointing to the texel where the data is stored.

pp_texel

The UVs of the root element (the trunk, index 0) are collapsed and centered on the first texel, where its attributes—such as position and axis—are stored in the RGB channels of one or more textures. The parent index is also stored here, usually in the alpha channel, which, for the root element, is its own index.

The UVs of the object with index 1 (the first branch) are collapsed and centered on the second texel, where its own attributes are stored, along with its parent index (trunk, index 0). Since the parent index for this element is 0, we know the parent’s attributes are found in the first texel, which can then be sampled to retrieve the parent’s position, axis, and so on.

pp_pivot

Note

The texture(s) generated by this algorithm don’t make any visual sense, as they contain uncorrelated, arbitrary data—often far outside the visible [0:1] range—baked per pixel.

By comparing an element’s own index to its parent index, the algorithm can determine whether an object is parented. If the indices are the same (indicating it's parented to itself), the element is considered a root.

The element’s own index isn’t stored in the texture because it’s not needed. Since the index was used to generate UV coordinates for its vertices, the UV, along with the texture size, can be used to reconstruct the index. A UV pointing to the 35th texel in the third row corresponds to index 234, assuming the texture is 100 pixels wide.

Following this principle, an element sampling the texel at its UV coordinate can read its parent index to compute the texel coordinate needed to retrieve the parent element’s data—whatever is stored in the RGB channels, such as position, and the parent’s own parent index in the alpha channel.

This process can be repeated for all elements, as many times as necessary, to traverse up the hierarchy and sample each ancestor’s attributes: an element’s own position, its parent’s position, its grandparent’s position, and so on.

pp_sample

One additional benefit of this technique—beyond supporting hierarchies of arbitrary depth—is that it allows any amount of data to be baked and accessed using extra textures, as long as the texel order is preserved: the first texel must store the data for the first element, the second texel for the second element, and so on. This ensures that the parent index in the hierarchy texture always points to the correct texel containing the parent’s data.

In addition to the texture storing the pivot position in RGB and the parent index in A, Pivot Painter typically uses a second texture to store the pivot axis in RGB and the element’s extents along that axis in A.

pp_axis

This enables not only the determination of the position around which a branch may pivot, but also the direction it faces and the length of the branch. This opens the door to more advanced vertex shaders: for example, wind effects can be adjusted based on the wind direction and the branch orientation. Wind samples can also be offset by the branch’s length, giving the impression that leaves carry more weight and play a bigger role in the branch's motion.

The main drawback of Pivot Painter is its cost. Imagine the GPU needs to perform a simple addition. Let’s use a metaphor: the GPU is a cook, and the addition is like adding pepper to a dish. The pepper is right in front of you — quick and easy!

Now, imagine the GPU needs to sample a texture. For the cook, this means adding an ingredient that’s stored in a freezer across the kitchen. It’s still doable, but not nearly as fast.

The hidden cost of Pivot Painter comes from something called dependent texture fetches. To climb the hierarchy, the GPU first samples the texture to get the parent index. That’s like the cook running to the freezer to grab one ingredient — not ideal, but manageable. Then, using that parent index, the GPU calculates new UV coordinates to find the grandparent index… and samples the texture again. Time for another trip across the kitchen.

You see the problem: each texture sample requires another round trip. These samples depend on one another, and the GPU has to wait for each step to finish before moving on. The deeper the hierarchy, the longer the wait — and the more expensive the vertex shader becomes. For example, sampling a vertex element’s own pivot point, then climbing up three levels to sample its parents — four dependent texture fetches in total — is considered highly expensive. Even on modern hardware, vertex processing hasn’t improved significantly in speed, so it’s easy to hit a bottleneck.

The cost of dependent texture fetches is the price you pay for an algorithm that allows unlimited jumps from parent to parent—but it's not the only option. If we impose limitations on the number of parents an element can have, the process can be handled differently, avoiding dependent texture fetches altogether.

Pivot Painter’s standard method bakes an element’s XYZ position into the RGB channels of a texture and stores its parent index in the alpha channel. Alternatively, the element’s XYZ position can still be baked into the RGB channels, while leaving the alpha channel blank.

A separate texture could then store the hierarchy: for example, the parent index in the red channel, the grandparent index in the green channel, and the great-grandparent index in the blue channel. This allows sampling the entire hierarchy—assuming it doesn't go deeper than two levels—with a single texture fetch.

The texture(s) containing the element’s data—such as position, axis, and more—are still sampled once per depth level to retrieve information about the parent, grandparent, and so on, just like before. This approach remains costly due to the number of texture fetches, much like the standard Pivot Painter method. However, the multiple dependent texture fetches previously required to reconstruct the hierarchy are now replaced by a single texture fetch. This can improve performance, albeit at the expense of some functionality.

Important

A unique aspect of Pivot Painter is its index packing algorithm. Storing the parent element index in a texture isn't as simple as it might seem. While using a 32-bit HDR texture would eliminate precision issues, it's excessive for this use case—since the data is baked in local space relative to the mesh, where 16-bit precision is typically sufficient. Using 16-bit textures also significantly reduces memory bandwidth, which is already a known bottleneck in this algorithm.

However, 16-bit floats can only accurately represent integers within the range of [-2048: 2048], which limits the number of unique elements you can work with. To overcome this, a clever bit-packing technique was developed: it encodes a 16-bit integer into a 16-bit float in a way that allows it to survive the automatic float conversions (from 16-bit to 32-bit and back) that occur when sampling textures. Its documented here.

Documentation

Blender has had addons implementing Pivot Painter for a very long time—possibly even before the 2.8 era. While I can’t say for certain who authored the very first addon, I’m fairly sure it was, at the time, a direct 1:1 copy of Jonathan Lindquist’s original 3ds Max script.

Due to the complexity of the algorithm, efforts to keep the addon functional through the many changes to Blender’s Python API mostly involved forking it and fixing whatever broke. As a result, the addon remained largely unchanged over the years.

This led to several issues:

  • Difficulty finding the most up-to-date implementation
  • Features implemented using now very deprecated methods
  • An old, unintuitive user experience
  • Undocumented code & features
  • A cluttered interface and codebase filled with unpopular features, stemming from the original goal of replicating the 3ds Max script exactly
  • Maintenance challenges, as the code evolved into something only a few people could understand, with what felt like duct tape solutions holding it together

This isn’t a critique in any way—any effort to share something freely with the community deserves appreciation. Shout-out to Gvgeo and the other authors who have kept Pivot Painter for Blender alive.

However, while doing research on the script and gaining a deeper understanding of the algorithm’s inner workings, I realized it didn’t have to be this way. The Pivot Painter algorithm could be implemented in a more modern and flexible way.

That’s why this tool is named "Object Attributes" (OA). It allows you to bake an object’s attributes—including its hierarchy—in a general and customizable way, without forcing users to follow Pivot Painter’s traditional approach. For example, you can now bake an element’s XYZ position into one texture and its XYZ axis into a second texture, without needing to consider hierarchy at all, if position and axis is all you care about. Or you can bake a specific number of hierarchy levels into a separate texture, as explained earlier. Or, if you prefer, you can still follow the classic Pivot Painter method.

A lot of effort went into making the process of baking object attributes as straightforward as possible. But before we dive into it, let’s quickly review the common options and panels.

Panel - Hierarchy

A panel called 'Hierarchy' lets you customize several options related to how the hierarchy is baked.

pp_panel_hierarchy

  • Use Pivot Painter: Enable the use of Pivot Painter’s 16-bit integer to 16-bit float packing algorithm to store the index. If disabled, the index will be stored as-is in a float. When packing is enabled, the index must be decoded before use; otherwise, it can be read directly. Note that 16-bit floats can only reliably store integers as-is up to 2048!
  • Limit Depth: Enable this option to prevent the hierarchy from becoming too deep. At the specified depth, children will be treated as part of their parent and will share the parent’s object data—such as position, axis, and more—as if they were part of the same mesh. Non-mesh objects within the hierarchy will be discarded and treated as if they do not exist by the algorithm, without affecting the transforms of their children.
  • Limit: Specifies the maximum depth allowed for the hierarchy. A value of 1 allows the tree to contain a parent and its children; a value of 2 includes a parent, children, and grandchildren, and so on.
  • Filter Selection: Filters the active selection to highlight objects that'd be depth-limited according to the current settings. Depth-limited objects behave as if they were an integral part of their parent.

Panel - Mesh

A panel called 'Mesh' lets you customize several options related to generating & exporting the baked mesh.

pp_panel_mesh

  • Scale: Scale factor for the baked offsets/positions. This compensates for Blender's default unit (1 meter) and aligns with the target application's unit system. A default factor of 100 is used to convert from meters to centimeters, Unreal's default unit
  • Invert X/Y/Z: Invert the world X/Y/Z axis (Y set to True for Unreal Engine compatibility)
  • Name: Name of the baked object
    • UV|Name: Name of the UVMap to be created or used for baking mesh UVs
    • UV|Invert V: Invert the V axis of the UVMap and flip the VAT texture(s) upside down. Typically True for exporting to Unreal Engine or DirectX apps, False for Unity or OpenGL apps
  • Merge: Enable merging of the duplicated selection once baking is complete. Otherwise, keep them separated to allow for additional bakes on the individual objects
  • Duplicate: Enable this option to preserve the original selection and bake data on the duplicated mesh. Disable it at your own risk—doing so will modify the selection, which may lead to unwanted changes to the source data and unpredictable bake results if data blocks are shared
  • Single User: If the selection isn't duplicated, the bake may not work as expected when data blocks are shared. This ensures that meshes are made 'single user' to prevent conflicts during the baking process
  • Export: Enable to export the generated mesh to an FBX file upon bake completion
    • Export|Name: Name for the exported FBX file (without the .fbx extension). is a placeholder tag that can be used to be replaced with the object's name
    • Export|Path: File path for the exported FBX, excluding the file name. The path is relative to the Blender file if saved, or absolute otherwise
    • Export|Advanced|Override: Enable to override any existing .fbx file

Panel - Textures

A panel called 'Textures' lets you customize several options related to generating and exporting the attribute textures.

pp_panel_texture

  • Max Width: Maximum allowed texture width. Exceeding this may cancel the bake. 256 is recommended, as 256^2 allows the baking of up to 65K of elements, more than the precision offered by Pivot Painter's packing algorithm
  • Max Height: Maximum allowed texture height. Exceeding this may cancel the bake. 256 is recommended, as 256^2 allows the baking of up to 65K of elements, more than the precision offered by Pivot Painter's packing algorithm
  • Power of Two: Force textures to be power-of-two sizes. Not recommended, as non-power-of-two textures ensure tight packing and are widely supported
  • Square: Force texture width and height to be equal if 'Power of Two' is enabled. Typically unnecessary, but provided as an option for specific use cases
  • Export: Enable to export the generated textures to an EXR file upon bake completion
    • Export|Filename: Name for the texture file (without the .exr extension). is a placeholder tag that can be used to be replaced with the object's name. is a placeholder tag that can be used to be replaced with the texture's custom name
    • Export|Path: Texture file path, excluding the file name. The path is relative to the Blender file if saved, or absolute otherwise
      • Export|Advanced|Override: Enable to override any existing .exr file

Panel - XML

A panel called 'XML' lets you customize the export options for the XML file.

pp_panel_xml

  • Export: Enable to export an XML file containing information about the bake process (recommended)
    • Export|Mode: Select how the XML file name and path are generated
      • Mesh Path: Use the same FBX file name and path for the XML file. Defaults to 'Custom' if mesh is not exported
      • Custom Path: Specify a custom XML file name and path
  • Export|Filename: Name for the exported XML file (without the .xml extension)
  • Export|Path: Path for the exported XML file, excluding the file name. The path is relative to the Blender file
  • Export|Override: Enable to override any existing .xml file

Bake - Pivot Painter Example

The Object Attributes Baker relies on the actual relationships between objects to bake the hierarchy and object attributes. To define the desired hierarchy, simply parent the objects accordingly—the bake will reflect this structure.

pp_hierarchy_01

Each object’s origin must be correctly positioned (and oriented as well, especially if you intend to bake its axis or compute the object’s extents along a specific axis) and properly parented to form a valid hierarchy.

pp_hierarchy_03

The hierarchy depth can be limited, allowing the algorithm to treat children at a certain depth as part of their parent—without needing to change the actual hierarchy. This is especially useful when working with LODs or managing large numbers of objects within a complex structure that would otherwise be too cumbersome to modify. The 'Filter Selection' operator allows you to preview this behavior before baking. Simply select all the desired objects, set the depth limit, and press Filter Selection to remove from the selection any objects that won’t be affected by the limit.

pp_hierarchy_02

Keep in mind that a leaf can be parented directly to the trunk, but it will still be treated as depth 2—just like a branch.

pp_hierarchy_04

While this technically works, it may lead to issues later in shaders when trying to differentiate leaves from branches or twigs based on depth. For instance, you might want to apply rotations with specific amplitude and frequency to leaves, but the leaf highlighted in the illustration above will inherit any rotation applied to branches.

Note

Separate hierarchies are supported, allowing you to bake multiple trunks at once—each with its own branches, twigs, and so on.

Keep in mind that only mesh objects are baked. The tool will ignore any non-mesh objects in the hierarchy. In the example below, the mesh named 'leaf'—originally parented to a curve, which is parented to an empty, which in turn is parented to the root mesh—will be treated as if it is directly parented to the root mesh.

pp_hierarchy_05

Blender offers many handy tools to help you modify the hierarchy and pivots. Since this isn't intended to be a Blender tutorial, I won't go too deep into that topic here—just be aware that you have the option to move and rotate pivots as needed, with or without affecting the hierarchy.

blender_pivot

Once the objects are set up, the next step is to list the attributes to bake for each element. Simply click the “+” icon in the interface.

pp_panel_channels_01

This will add a new texture to the list, which you can rename by double-clicking it. This will also populate the 'Channels' subpanel underneath with new options.

pp_panel_channels_02

Important

Each texture must have a unique name!

Next, define what data to store in each of the texture’s RGBA channels. This setup follows Pivot Painter’s standard approach.

pp_panel_channels_03

Note

You could choose to store only the XY components of the position if that’s all you need, which would free up the blue channel to store the parent index instead of using the alpha channel. This tool offers great flexibility. With great power comes great... something.

Important

Since you're specifying data per channel, it’s important to assign the correct XYZ component of each vector (position, axis, etc.) to the appropriate channel. Typically the X component in the red channel, the Y component in the green channel and the Z component in the blue channel.

You can also add a second texture to store additional data—Pivot Painter typically uses this to store the pivot axis and the element’s extents along that axis.

pp_panel_channels_04

Note

You can choose to bake the forward, right, or up axis vector—each of which requires splitting its X, Y, and Z components across three separate texture channels. There’s no right or wrong choice here; it simply depends on how your objects are oriented and which axis you wish to sample in the shader. Blender provides the option to display axes per object, which is a helpful way to visualize what will be baked.

Most types of data can be remapped to the [0:1] range using the 'Remap' feature, which allows them to be stored in an 8-bit texture—provided you're aware of the limitations that come with such limited precision. When reading the data back from the texture, it needs to be remapped to its original [-min, max] range using the reported 'offset' and range 'values'.

Most data can also be made relative to the parent element, rather than the world, using the 'Reference' feature. This is especially useful when storing positional data in 8-bit RGBA textures, as it helps make the most of the limited precision. However, this requires the data to be handled differently in the shader. For example, positions relative to the parent likely need to be added at each depth level in the shader to reconstruct the final pivot position relative to the static mesh’s origin—though this can vary depending on your use case.

The tool also lets you specify the source object when baking attributes using the 'Source' feature. This is mainly intended for advanced workflows, as using "self" (the object itself) is the correct choice in nearly all situations. The ability to use the object's parent or even a custom object was added to provide flexibility for rare or specialized scenarios. You can also use a custom property stored in each object to define the source. Each object can target another object, which may be useful in specific cases.

Note

Because the tool offers many possibilities, it can be configured into unusual or unintended states. For example, baking the parent element’s data relative to the parent element can obviously lead to undefined behavior—such as a position or scale of zero.

pp_bake_channels

The 'Source' feature can be useful for replacing an object's transform with another by referencing a different object through a data-block custom property. For instance, when baking an object’s quaternion—which represents its rotation—you may want the object to be unrotated post-bake. This allows the quaternion to be reapplied later in the shader to restore the original rotation, rather than adding it on top of an already rotated object. This can be tricky to manage unless you disable the 'Merge' feature in the 'Mesh' panel, and reset the object’s rotation manually after baking.

Alternatively, you can use the 'Source' feature: leave the objects you want to bake unrotated, but have each one point to a rotated empty using a data-block custom property. Then, in the shader, you can rotate the unrotated objects using the baked quaternion to match the empties. The quaternion itself can even be bit-packed into a single float using the "smallest three" method, which requires decoding before converting it into an axis and angle (as well as using a 32-bit HDR texture).

The following illustration shows a possible workflow for using baked quaternions.

pp_bake_quaternion

Important

Angle is in radians, so it must be converted to degrees and then divided by 360° to be back in the [0:1] range the 'RotateAboutAxis' node expects.

Bake - Alternative Example

As mentioned earlier, Pivot Painter’s strength lies in its ability to traverse the hierarchy by jumping from parent to parent indefinitely—at the cost of additional latency for each level sampled. This tool, however, no longer strictly follows Pivot Painter’s approach and now allows you to bake not only the parent index, but also the indices of the grandparent, great-grandparent, and so on.

With this new paradigm, it’s now possible to create a dedicated hierarchy texture that stores up to four indices per element.

pp_bake_hierarchy

Another texture can then be used to store the element’s XYZ position in the RGB channels, while a separate texture may store the XYZ axis and the extents along that axis in its RGBA channels.

While this setup introduces a constraint on the maximum hierarchy depth (though you can use a second hierarchy texture to store four additional indices, allowing for a maximum depth of eight), it eliminates the need for dependent texture fetches—potentially improving performance in shaders.

pp_bake_hierarchy_mat

Note

The performance gain is not substantial, as the process still involves numerous texture samples to retrieve the baked data after reconstructing the hierarchy. The main improvement comes from fetching parent indices in a single texture lookup, rather than through multiple dependent fetches. In a test scene with 256 meshes, each having 130K vertices, the dependent fetch approach took 23.25 ms, while the alternative method completed in 22.7 ms.

Bake - Result

The bake should generate a new mesh object, with the attribute image(s) saved within the Blender file.

UVMap

It’s a good idea to check the generated mesh’s vertex-to-texel UVMap.

pp_bake_result_uv

Mesh

Simply import the .fbx into your game engine. In Unreal Engine, you may want to check the following import options.

pp_mesh_import

Note

If it wasn’t automatically exported, you’ll need to manually export the generated mesh to .fbx. Blender’s default export settings should work fine.

Important

Mesh bounds may need to be adjusted to account for any vertex transformations you plan to apply using the baked data.

Textures

Simply import the .exr textures into your game engine.

Each may need to be compressed in different formats:

  • Uncompressed 8-bit RGBA: Unlikely, but possible if data was remapped to the [0:1] range and you're aware of the limitations of working within the 8-bit range. This however exclude texture(s) storing element indices—unless you have less than 256 elements to bake—and an XYZW quaternion bit-packed into a single 32-bit float.
  • Uncompressed 16-bit HDR: Likely the best choice for most use cases—especially for the texture storing the parent element’s index if you’re using Pivot Painter’s packing algorithm. Textures storing positional data, axes, and similar information are probably fine with 16-bit floats, since the data is baked in local space, relative to the static mesh’s origin, where 16-bit precision is typically sufficient. This exclude texture(s) storing an XYZW quaternion bit-packed into a single 32-bit float.
  • Uncompressed 32-bit HDR: This might be required for the texture storing the parent element’s index if you’re baking more than 2048 elements and not using Pivot Painter’s packing algorithm. It’s the safest option for all textures, but comes with a higher memory cost.

pp_bake_tex_compression

Nearest sampling is required to prevent pixel interpolation. Each element needs to sample a specific texel, and any interpolation with unrelated neighboring data can corrupt the result—especially when using Pivot Painter’s packing algorithm, due to the bit scrambling it introduces.

pp_texture_filter

Note

If the textures weren’t automatically exported, you’ll need to manually export them as .exr using the following settings.

vat_export_img_settings

Report

Once a bake is attempted, a report panel will appear in the 'Object Attributes' panel, providing valuable insights into the baked information.

pp_panel_report

  • Export: Exports the report to an XML file following the XML export settings.
  • Clear: Clears the report. This may be useful as the report holds pointers to the baked object(s), which will make them persist in the Blender file even though they are later deleted.

This panel provides global information about the bake that won’t be covered in detail in this documentation to avoid unnecessary clutter, as most of the information should be self-explanatory. Just make sure you’re aware of the coordinate system used by both Blender and your target application, so you can account for differences in world axes, UV orientation, and world scale.

For data that has been remapped to the [0:1] range for potential storage in 8-bit RGBA texture(s), it must be converted back to its original [-min:max] range using the reported 'offset' and 'range' values: $(value * range) + offset$. This information is still displayed—though grayed out—for values that have not been remapped to the [0, 1] range, serving purely as a reference.

Unreal Engine

The process of reconstructing the hierarchy from the baked data is not as straightforward as one might think.

The first step is to get the element index, which can be computed from the dedicated vertex-to-texel UV map, as explained earlier.

pp_ue_01

Using that UV map, we sample the texture containing the parent indices a first time. By comparing the element index and the parent index, we can determine whether the element is parented to another. The parent index then needs to be converted back into UV coordinates using the texture size. With these UVs, the texture containing the parent indices can be sampled again. This process is repeated for as many levels as the hierarchy contains: at each depth level, we fetch the element's parent index, compute its UVs, and whether it is parented.

pp_ue_02

Note

The purple nodes are called Named Reroutes. They allow you to reuse connections in different parts of the graph, helping to keep the layout clean and avoid a spaghetti mess.

The goal is then to sample the texture containing the data we want to retrieve—such as position or axis—to reconstruct the hierarchy’s transformations. However, this is not as simple as just using the UVs from each depth level. Here's why: imagine a tree with the following hierarchy—$trunk > branch > twig > leaf$. Rotating the trunk should also rotate the branches, twigs, and leaves, as if they were all physically connected. That’s the purpose of Pivot Painter: to reconstruct this kind of hierarchy.

Every vertex in the tree needs to rotate around the trunk’s pivot point, which is stored at index 0. For trunk vertices, this data comes from the element itself. For branch vertices, it comes from the parent. For twig vertices, it comes from the grandparent, and so on. You get the idea: depending on where the element is in the hierarchy, the data required to rotate the entire tree correctly varies. This is why we need an extra step—shuffling the UVs.

For all vertices, we calculate the depth by summing the 'is-parented' booleans, converting them into 0 or 1 integers. A trunk vertex has a depth of 0, while a leaf vertex would have a depth of 3.

Using this depth value, we can then select the correct UVs to apply a rotation that matches the hierarchy level. At depth 0, we’re rotating the entire tree around the trunk's pivot point. Vertices part of the trunk fetch the pivot from the element itself, while branches fetch the pivot from their parent, twigs from their grand-parent and so on.

We also need to compute a depth mask. This is important because while a rotation around the trunk's pivot applies to all vertices, a rotation at depth 1—for example, applied to branches—should not affect vertices that belong to the trunk. The depth mask ensures that each rotation is only applied where it should be.

pp_ue_03

Once UVs are shuffled, the hierarchy data can be sampled from the necessary textures: pivots, axis, etc.

pp_ue_04

Once the hierarchy data has been sampled for all necessary depths, it's up to you to transform the vertices using the method of your choice. In the case of a tree, this usually means applying simple rotations. However, performing these rotations in a hierarchical structure can be challenging for several reasons.

In Unreal Engine, for example, the final vertex data to compute usually consists of:

  • An offset from the base position
  • A normal, optionally

While rotating the normal is relatively straightforward, handling the position is more complex. If we start by rotating the entire tree around the trunk’s pivot point, it causes a side effect: the branches rotate along. As a result, when we try to apply a rotation to the branches and their children afterward, we’ll find that the branches’ pivot points, sampled from the data baked in textures, no longer correspond to their rotated pivot point, because of the trunk rotation!

Fortunately, there’s a simpler and more effective approach: apply rotations using a reversed hierarchy.

Start by rotating the leaves around their own pivot points. Then rotate the twigs (which also affects the leaves), followed by the branches, and finally the trunk. This way, each element's pivot point remains valid at the moment its rotation is applied.

The most common way to apply a rotation in Unreal Engine is by using the RotateAboutAxis node. This node has a side effect we don’t usually need to worry about—except in this case. It assumes we are working with offsets, because that’s the typical use case. It takes a position, rotates it around a pivot, and then converts the rotated position into an offset by subtracting the input position.

This behavior is fine for isolated rotations, but it causes issues when chaining multiple rotations. That’s because the next rotation needs to be applied to the actual rotated position—not to the offset. That’s why, when working hierarchically, we need to accumulate or re-add the previous rotation’s input position before applying the next one.

pp_ue_05

Important

The final rotated position is converted into an offset by subtracting the original base position. This offset is then transformed from local to world space before being connected to the WPO (World Position Offset) input in the material.

Applying rotations in reverse order is not an issue when rotating the vertex normal, since rotating a unit vector is always done relative to the origin (0,0,0). This means the order of rotations—forward or reverse—doesn’t affect the final result. However, for consistency, it’s recommended to follow the same order used for the World Position Offset (WPO).

pp_ue_06

Important

The same principle applies here: the RotateAboutAxis node outputs an offset. To get the actual rotated unit vector, we need to add the input normal to the result. The resulting vector is then normalized and passed through a Vertex Interpolator to avoid performing trigonometric calculations per pixel. Finally, the interpolated normal is converted to tangent space in the pixel shader.

Note

The Unreal Engine Pivot Painter example content provided by Epic is flawed: rotations are applied individually at each depth using the forward hierarchy, and the resulting offsets are simply added together. This naive approach leads to visible deformation and skewing. The reversed-hierarchy method described above avoids these issues entirely—producing correct results without any downsides.

The following illustration highlights two important points to keep in mind when using remapping and positions baked relative to parent elements:

  • When remapping, the data sampled from the texture must be multiplied by the reported 'range' value, and then the 'offset' should be added. This applies only to the channels that were remapped, not necessarily the entire texture. In this case, the position's X, Y, and Z components were all remapped, so the RGB values sampled from the texture are scaled and offset using the corresponding XYZ values.
  • When baking positions relative to parent elements, the positional data no longer represents an absolute position, but rather an offset from the parent. To reconstruct the actual position, these offsets must be added together along the hierarchy. For a leaf at depth 4, this means: trunk offset from the origin + branch offset from the trunk + twig offset from the branch + leaf offset from the twig.

pp_bake_hierarchy_parent

Troubleshooting

Many things can go south when baking object attributes in texture(s). Here are some things to look out for.

  • Double-check that the differences between Blender’s coordinate systems and those of your target application have been properly accounted for:
    • Blender’s world axes are +X+Y+Z, while Unreal Engine’s world axes are +X-Y+Z.
    • Blender uses OpenGL, meaning UV(0.0) is at the bottom-left corner, whereas Unreal Engine uses DirectX, where UV(0,0) is at the top-left corner.
    • Blender’s default unit is 1 meter (though it can be adjusted), while Unreal Engine’s default unit is 1 centimeter, so a scale factor may need to be applied.
  • Try 32-bit UVs in your targeted application if you think the issue you experience is related to 16-bit UVs imprecision.
  • Double check the texture compression settings in your targeted application. In doubt, try uncompressed 32-bit HDR textures first, then 16-bit HDR textures. RGBA8 may be an option if you know what you're doing.
  • Double check the texture sampling settings in your targeted application. Nearest sampling is mandatory.
  • Double check the generated mesh OA UVs are laid out as expected.
  • Ensure OA UVs are not overriden in your targeted application by lightmap UVs or any other process automated during mesh import.
  • Disable any geometry-related optimization features in your game engine initially. This includes virtualized geometry systems like UE's Nanite, which may need to be deactivated both project-wide and on a per-asset basis.
  • Make sure the hierarchy is properly built in Blender and properly baked (pay attention to the Depth Limit feature, etc).
  • Rebuilding the hierarchy in a shader is a multi-step process where even the smallest mistake can lead to major visual errors or completely break the system.
  • ...

Presets

The Pivot Painter tool's configuration and settings can be saved as a preset, which can be easily applied with a single click at any time. Presets can be added or removed using the button located to the right of the Pivot Painter tool header.

preset

This feature uses Blender's internal preset system, which creates Python files to store all the necessary data. These files are stored in the following directories:

- Windows: C:\Users\<your username>\AppData\Roaming\Blender Foundation\Blender\<version number>\scripts\presets\operator\databaker_pivotpainter\
- MAC: Library\Application Support\Blender\<version number>\scripts\presets\operator\databaker_pivotpainter\
- Linux: ~/.config/blender/<version number>/scripts/presets/operator/databaker_pivotpainter/

Warning

Preset .py files can be copied, pasted, and shared across different computers. However, only install presets from trusted sources, as these files can execute malicious Python code on your machine.

Extending the tool

Here are a few words about the underlying Object Attributes Baker implementation.

You can add new properties to the OBJECTATTRIBUTES_PG_SettingsPropertyGroup class located in the Properties.py file. This class stores global settings for the tool.

class OBJECTATTRIBUTES_PG_SettingsPropertyGroup(PropertyGroup):
    ...
    textures: CollectionProperty(type=OBJECTATTRIBUTES_PG_TexLayerPropertyGroup)
    textures_selected_index: IntProperty(name="Selected", default=0)

    depth_limit_use: BoolProperty(name="Limit Depth", default=True, description="Enable this option to prevent the hierarchy from becoming too deep. At the specified depth, children will be treated as part of their parent and will share the parent’s object data—such as position, axis, and more—as if they were part of the same mesh. Non-mesh objects within the hierarchy will be discarded and treated as if they do not exist by the algorithm, without affecting the transforms of their children")
    depth_limit: IntProperty(name="Limit", default=3, min=0, description="Specifies the maximum depth allowed for the hierarchy. A value of 1 allows the tree to contain a parent and its children; a value of 2 includes a parent, children, and grandchildren, and so on")
    use_pivot_painter_packing: BoolProperty(name="Use Pivot Painter Packing", default=True, description="Enable the use of Pivot Painter’s 16-bit integer to 16-bit float packing algorithm to store the index. If disabled, the index will be stored as-is in a float. When packing is enabled, the index must be decoded before use; otherwise, it can be read directly. Note that 16-bit floats can only reliably store integers as-is up to 2048")

    mesh_name: StringProperty(name="Name", default="BakedMesh.OA", description="Name of the resulting baked mesh")
    ...

Take note of the textures: CollectionProperty, which references the OBJECTATTRIBUTES_PG_TexLayerPropertyGroup class. This class defines all properties that can be used to describe a texture. You'll note that it's quite minimal. It's mostly made of four CollectionProperty, which references the OBJECTATTRIBUTES_PG_TexChannelPropertyGroup.

class OBJECTATTRIBUTES_PG_TexLayerPropertyGroup(PropertyGroup):
    ID: StringProperty(name="ID", default="", description="")
    name: StringProperty(name="name", default="Texture", description="")

    R: PointerProperty(type=OBJECTATTRIBUTES_PG_TexChannelPropertyGroup)
    G: PointerProperty(type=OBJECTATTRIBUTES_PG_TexChannelPropertyGroup)
    B: PointerProperty(type=OBJECTATTRIBUTES_PG_TexChannelPropertyGroup)
    A: PointerProperty(type=OBJECTATTRIBUTES_PG_TexChannelPropertyGroup)

The OBJECTATTRIBUTES_PG_TexChannelPropertyGroup class is used to describe what to bake in a specific channel of a texture.

class OBJECTATTRIBUTES_PG_TexChannelPropertyGroup(PropertyGroup):
    channel_modes = [
        ("NONE", "None", "Write 0 to the channel"),
        ("POSITION", "Position", "X/Y/Z component of the object's position"),
        ("AXIS", "Axis", "X/Y/Z component of the object's forward/right/up vector"),
        ("EXTENTS", "Extents", "Length of the object along its forward/right/up vector"),
        ("HIERARCHY", "Hierarchy", "Object's linear index in the hierarchy"),
        ("CUSTOM_PROP", "Custom Property", "Object's Float/Integer custom property"),
        ("QUATERNION", "Quaternion", "X/Y/Z/W component of the object's orientation, or the XYZW components bit-packed into a single float using the smallest-three method")
    ]
    channel_mode: EnumProperty(items=channel_modes, name="Mode", description= "", default="NONE")

    component_x_y_z = [
        ("X", "X", "The vector's X component"),
        ("Y", "Y", "The vector's Y component"),
        ("Z", "Z", "The vector's Z component")
    ]
    component: EnumProperty(name="Component", items=component_x_y_z, default="X", description="Component to bake")

    ...

You're free to add your own properties if needed, either to the settings property class, the texture property class or texture channel property class.

To extend the tool, you'll likely need to add a new entry to the channel_modes enum list inside the OBJECTATTRIBUTES_PG_TexChannelPropertyGroup to support your new bake mode.

channel_modes = [
        ("NONE", "None", "Write 0 to the channel"),
        ("POSITION", "Position", "X/Y/Z component of the object's position"),
        ("AXIS", "Axis", "X/Y/Z component of the object's forward/right/up vector"),
        ("EXTENTS", "Extents", "Length of the object along its forward/right/up vector"),
        ("HIERARCHY", "Hierarchy", "Object's linear index in the hierarchy"),
        ("CUSTOM_PROP", "Custom Property", "Object's Float/Integer custom property"),
        ("QUATERNION", "Quaternion", "X/Y/Z/W component of the object's orientation, or the XYZW components bit-packed into a single float using the smallest-three method")
    ]

Still in the Properties.py file, there's a collection property called OBJECTATTRIBUTES_PG_ReportPropertyGroup, which defines all the properties that must be saved after a bake. It includes the same textures list as in the OBJECTATTRIBUTES_PG_SettingsPropertyGroup class, acting as a direct duplicate. A second class, OBJECTATTRIBUTES_PG_TexLayerReportPropertyGroup, also serves as a direct duplicate of the OBJECTATTRIBUTES_PG_TexLayerPropertyGroup class, but includes additional informative properties that were not originally intended to be exposed to the user as settings. The latter duplication process takes place in the add_bake_texture_report() function, located in the Functions.py file.

class OBJECTATTRIBUTES_PG_ReportPropertyGroup(PropertyGroup):
    baked: BoolProperty(name="Baked", default=False, description="")
    success: BoolProperty(name="Success", default=False, description="")
    msg: StringProperty(name="Message", default="", description="")
    name: StringProperty(name="Name", default="", description="")
    ID: StringProperty(name="ID", default="", description="")
    ...
class OBJECTATTRIBUTES_PG_TexLayerReportPropertyGroup(PropertyGroup):
    """ """
    ID: StringProperty(name="ID", default="", description="")
    name: StringProperty(name="name", default="Texture", description="")
    exported: BoolProperty(name="Exported", default=False)
    path: StringProperty(name="Texture Filepath", default="//", description="", subtype='FILE_PATH')
    img: PointerProperty(type=bpy.types.Image)
    ...

Next, any new property must be made visible to the user. This is handled in the Panels.py file. Properties added to the OBJECTATTRIBUTES_PG_SettingsPropertyGroup can be displayed in the OBJECTATTRIBUTES_PT_MainPanel panel. From there, the currently selected texture can be accessed, and the UI updated accordingly based on the texture's four individual channel properties.

class OBJECTATTRIBUTES_PT_MainPanel(bpy.types.Panel):
    bl_idname = "OBJECTATTRIBUTES_PT_mainpanel"
    bl_label = "Object Attributes"		
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Game Tools"
    bl_order = 1

    bl_options = {'DEFAULT_CLOSED'}
    
    ...

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        settings = scene.ObjectAttributesSettings

        row = layout.row()
        row.scale_y = 2.0 # bigger button
        row.operator("gametools.databaker_bakeoa")
        row.enabled = len(settings.textures) > 0

        row = layout.row()
        row.template_list("OBJECTATTRIBUTES_UL_TextureList", "", settings, "textures", settings, "textures_selected_index", rows=5)

        col = row.column(align=True)
        col.operator("objectattributes_item.new_item", text="", icon="ADD")
        col.operator("objectattributes_item.delete_item", text="", icon="REMOVE")

        col.separator()

        col.operator("objectattributes_item.move_item", text="", icon="TRIA_UP").direction = "UP"
        col.operator("objectattributes_item.move_item", text="", icon="TRIA_DOWN").direction = "DOWN"

Properties added to the OBJECTATTRIBUTES_PG_SettingsPropertyGroup can be shown wherever appropriate. For example, the OBJECTATTRIBUTES_PT_ChannelsPanel is a subpanel of the main OBJECTATTRIBUTES_PT_MainPanel panel and displays global settings related to the texture channels.

class OBJECTATTRIBUTES_PT_ChannelsPanel(bpy.types.Panel):
    bl_idname = "OBJECTATTRIBUTES_PT_channelspanel"
    bl_parent_id = "OBJECTATTRIBUTES_PT_mainpanel"
    bl_label = "Channels"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Game Tools"
    bl_order = 0
    
    #bl_options = {'DEFAULT_CLOSED'}

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        settings = scene.ObjectAttributesSettings

        if settings.textures:
            texture = settings.textures[settings.textures_selected_index]

            if texture:
                channels = [
                    (texture.R, "R"),
                    (texture.G, "G"),
                    (texture.B, "B"),
                    (texture.A, "A"),
                    ]

                for tex_data, tex_name in channels:
                    if tex_data.channel_mode == "NONE":
                        row = layout.row()
                        row.prop(tex_data, "channel_mode", text=tex_name)
        ...

Newly added settings must also be included in preset files. This takes place in the OBJECTATTRIBUTES_OT_ObjectAttributes_AddPreset operator, located in the Operators.py file.

class OBJECTATTRIBUTES_OT_ObjectAttributes_AddPreset(AddPresetBase, bpy.types.Operator):
    bl_idname = 'objectattributes_objectattributespanel.addpreset'
    bl_label = 'Add preset'
    preset_menu = 'OBJECTATTRIBUTES_MT_ObjectAttributes_Presets'

    preset_defines = [ 'settings = bpy.context.scene.ObjectAttributesSettings' ]
    preset_values = [
        'settings.textures',
        'settings.textures_selected_index',
        'settings.depth_limit_use',
        ...

Properties added to the OBJECTATTRIBUTES_PG_ReportPropertyGroup can be shown in one of the 'Report' panels. You may create your own if needed. The OBJECTATTRIBUTES_PT_ReportPanel is a good reference—it displays general bake information.

class OBJECTATTRIBUTES_PT_ReportPanel(bpy.types.Panel):
    bl_idname = "OBJECTATTRIBUTES_PT_reportpanel"
    bl_parent_id = "OBJECTATTRIBUTES_PT_mainpanel"
    bl_label = "Report"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Game Tools"
    bl_order = 500

    bl_options = {'DEFAULT_CLOSED'}

    ...

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        report = scene.ObjectAttributesReport

        if report.baked:
           ...

Important: any property added to the OBJECTATTRIBUTES_PG_ReportPropertyGroup must be reset at the start of a new bake. This happens inside the reset_bake_report() function in the Functions.py file.

def reset_bake_report():
    report = bpy.context.scene.ObjectAttributesReport

    report.baked = False
    report.success = False
    report.msg = ""
    report.name = ""
    report.ID = ""
    ...

The new_bake_report() function calls this reset function and sets global variables that are easily retrievable from the settings.

def new_bake_report(context: bpy.types.Context):
    settings = context.scene.ObjectAttributesSettings

    reset_bake_report()

    add_bake_report("baked", True)
    add_bake_report("ID", uuid.uuid4().hex)
    ...

To set values during the main bake process, use the add_bake_report() function.

def add_bake_report(prop_name: str, prop_value: float|int|str):
    setattr(bpy.context.scene.ObjectAttributesReport, prop_name, prop_value)

There are also utility methods to add, retrieve, and update properties of textures stored in the OBJECTATTRIBUTES_PG_ReportPropertyGroup.

def add_bake_texture_report(texture: object, img: bpy.types.Image):

def edit_bake_texture_report_prop(texture: object, value, prop_name: str):

def edit_bake_texture_report_path(texture: object, path: str):
 
def edit_bake_texture_report_exported(texture: object, exported: bool):

def clear_bake_texture_report(texture) -> bool:

Properties stored in the OBJECTATTRIBUTES_PG_ReportPropertyGroup are meant to be important information exposed to the user and exported to XML. The export_xml() function handles formatting the XML output using the properties defined in that property group.

def export_xml(context: bpy.types.Context) -> tuple[bool, str, str]:
    settings = context.scene.ObjectAttributesSettings
    report = context.scene.ObjectAttributesReport

    root = ET.Element("BakedData",
                      type="OA",
                      ID=report.ID,
                      version="1.0")

    # unit
    unit_el = ET.SubElement(root, "Unit",
                            system=report.unit_system,
                            unit=str(report.unit_unit),
                            length=str(report.unit_length),
                            unit_scale=str(report.unit_scale),
                            unit_invert_x=str(report.unit_invert_x),
                            unit_invert_y=str(report.unit_invert_y),
                            unit_invert_z=str(report.unit_invert_z))
    ...

Now, let’s talk about the core of the algorithm: the bake() function.

def bake(context: bpy.types.Context):
    bpy.ops.object.mode_set(mode="OBJECT")

    settings = context.scene.ObjectAttributesSettings
    new_bake_report(context)

    wm = bpy.context.window_manager
    wm.progress_begin(0, 99)

    #############
    # BAKE INFO #

    bake_start_time = time.time()

    ...

    ############
    # TEXTURES #

    dgraph = bpy.context.evaluated_depsgraph_get()

    ...
    for texture in textures:
        buffer = get_texture_buffer(context, dgraph, texture, eval_objs_to_bake, tex_width, tex_height, num_indices)
        ...

It handles various preprocessing steps before calling the main baking function: get_texture_buffer(context, dgraph, texture, eval_objs_to_bake, tex_width, tex_height, num_indices).


def get_texture_buffer(context: bpy.types.Context, dgraph: bpy.types.Depsgraph, texture: object, eval_objs_to_bake: list, tex_width: int, tex_height: int, attr_buffer_length: int) -> tuple[list, list, list, list]:
    buffer = [0.0] * tex_width * tex_height * 4 # RGBA
    
    texture_channels = [
        (texture.R if texture.R.channel_mode != "NONE" else None),
        (texture.G if texture.G.channel_mode != "NONE" else None),
        (texture.B if texture.B.channel_mode != "NONE" else None),
        (texture.A if texture.A.channel_mode != "NONE" else None),
        ]

    buffer_ranges_offsets = [0.0] * 4
    buffer_ranges = [1.0] * 4
    buffer_ranges_valid = [False] * 4

    for texture_channel_index, texture_channel in enumerate(texture_channels):
        if texture_channel is None:
            continue

        pre_bake_func = get_texture_buffer_function(texture_channel)
        obj_attr_buffer = pre_bake_func(context, dgraph, texture_channel, eval_objs_to_bake, attr_buffer_length)
        if obj_attr_buffer:
            if get_texture_channel_allow_remap(texture_channel):
                buffer_min = min(obj_attr_buffer)
                buffer_max = max(obj_attr_buffer)
                if abs(buffer_max - buffer_min) < 0.0001:
                    buffer_range = 1.0
                else:
                    buffer_range = buffer_max - buffer_min
                    buffer_ranges_valid[texture_channel_index] = True
                buffer_offset = buffer_min

                buffer_ranges_offsets[texture_channel_index] = buffer_offset
                buffer_ranges[texture_channel_index] = buffer_range

                if texture_channel.remapping:
                    obj_attr_buffer = [((data - buffer_min) / buffer_range) for data in obj_attr_buffer]

            for attr_index in range(len(obj_attr_buffer)):
                buffer[(attr_index * 4) + texture_channel_index] = obj_attr_buffer[attr_index]

    return (buffer, buffer_ranges_offsets, buffer_ranges, buffer_ranges_valid)

For each texture channel to bake, this function calls get_texture_buffer_function(), which returns the correct buffer function based on the texture channel's configuration.

def get_texture_buffer_function(texture_channel: object) -> callable:
    if texture_channel.channel_mode == "POSITION":
        return texture_buffer_position
    elif texture_channel.channel_mode == "AXIS":
        return texture_buffer_axis
    ...

    return texture_buffer_zeros

These sub-functions are defined in Functions.py, and the texture_buffer_zeros() function serves both as the default fallback and as a documented template. You're invited to copy it to create your own buffer function. This function is responsible for generating a buffer of single values to bake, per element, in the texture's channel.

def texture_buffer_zeros(context: bpy.types.Context, dgraph: bpy.types.Depsgraph, texture_channel: object, eval_objs_to_bake: list, attr_buffer_length: int) -> list:
    ...
    obj_attr_buffer = [0.0] * attr_buffer_length
    for eval_obj_to_bake in eval_objs_to_bake:
        if "ObjectAttributesHierarchyIndex" in eval_obj_to_bake:
            index = eval_obj_to_bake["ObjectAttributesHierarchyIndex"]
        else:
            continue

        ...
        data_to_bake = 0.0
        ...

        try:
            obj_attr_buffer[index] = data_to_bake
        except:
            pass

    return obj_attr_buffer

The script then combines the texture channel buffers to create a global pixel buffer for generating the texture—a process you generally don’t need to worry about.

Summary:

  • Add properties to the appropriate property groups: settings, texture, texture channel, or report.
  • Make sure these properties are displayed to the user in the appropriate panels.
  • Important properties in the report group must be reset at the start of a bake and likely need to be included in the XML export.
  • Add your new mode to the channel_modes enum list.
  • Update get_texture_buffer_function() to return your new buffer function for that mode.
  • Create a new buffer function that outputs the data to bake per element for that texture channel, following guidelines explained in the texture_buffer_zeros() function
  • Update panels to expose settings related to this new mode

Voilà! Have fun iterating on the tool and feel free to send PRs!

⚠️ **GitHub.com Fallback** ⚠️