Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
830b69c
fix 5.1 by Kelit
May 27, 2026
326c6b6
Enhance geometry validation to check evaluated geometry including mod…
May 27, 2026
bae4c0c
Force send2ue imports to use Unreal FBX factory
Jun 2, 2026
e88faa7
Revert "Force send2ue imports to use Unreal FBX factory"
Jun 6, 2026
59e14aa
Add material pipeline extension and view-layer guard for child selection
Jun 16, 2026
0f30304
Auto-derive Unreal mesh folder path from .blend file location
Jun 16, 2026
5c55db1
Sync mesh folder path on save too, plus edge-case hardening
Jun 16, 2026
03859e3
Avoid Unreal RPC path validation on every save
Jun 16, 2026
e8c31f1
Add armature-child binding fix, Hair Tool export, and skip-animation …
Jun 21, 2026
f343c46
Support Hair Tool mesh export workflow
Jun 22, 2026
2c6769c
Link the shared pipeline contract from the README
Jun 24, 2026
b801969
Apply transfer sources before mesh export
Jun 26, 2026
b6c1484
Overwrite shape keys during transfer export
Jun 26, 2026
b8dac2d
Read transfer contracts from handoff JSON
Jun 26, 2026
462a857
Bundle Send2UE material pipeline
Jun 26, 2026
2aeb1db
Use public handoff validator API
Jun 26, 2026
eea7d5e
Optimize material pipeline handoff import
Jun 26, 2026
b894db8
Document and harden Send2UE handoff automation
Jul 7, 2026
7c0a173
Update Send to Unreal material pipeline
Jul 13, 2026
03311c1
Scope Send2UE handoff and tree Nanite settings
Jul 13, 2026
eef62b4
Add Unreal groom and material handoff adapters
Jul 18, 2026
16ad93d
Pack hair RFAOS payload into Nanite UV channels
Jul 20, 2026
a2bec05
Protect production materials during isolated Codex tests
Jul 22, 2026
a14e171
Harden Send2UE material and skeletal asset handoff
Jul 30, 2026
ac4c8f4
Bind material sidecars to exact export identity
Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ A repository of blender addons that improve the game development workflow betwee
This is now maintained by [@JoshQuake](https://github.com/JoshQuake) and volunteers from the community and is not affiliated with Epic Games.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E3VWL1V)

> **This fork:** the PARK send2ue customizations are part of a Blender → Substance
> Painter → Unreal pipeline. Its shared conventions (the `Export` collection,
> naming prefixes, Unreal path anchors) are the contract documented in the
> `substance-tools` repo:
> [`docs/pipeline_contract.md`](https://github.com/nsdoanfosan/substance-tools/blob/main/docs/pipeline_contract.md)
> and `pipeline_contract.json`. Treat that as the source of truth before
> changing pipeline-facing names.
>
> The Send2UE material handoff extension is bundled in
> `src/addons/send2ue/resources/extensions/send2ue_material_pipeline.py`; its
> Unreal Python postprocess lives beside it in
> `src/addons/send2ue/resources/pipeline/ue_material_setup.py`. Set
> `UE_BLENDER_PIPELINE_DIR` only when deliberately overriding that bundled code.


## Send to Unreal

Expand Down
69 changes: 69 additions & 0 deletions docs/send2ue/extras/unreal-handoff-automation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Unreal Handoff Automation

This project has a few Send to Unreal handoff rules that are applied automatically during export and post-import. They are intentionally narrow so artist-authored Blender data can drive the handoff without creating replacement Unreal assets unexpectedly.

## Blender export preparation

### Hidden export armatures

Armatures that are directly placed in the `Export` collection are temporarily made visible while Send to Unreal prepares the export. Their original viewport visibility is restored during cleanup.

This keeps skeletal mesh exports stable when an artist hides the rig in Blender for viewport work. The export still sees the intended armature, but the Blender scene is returned to the artist's previous hidden/visible state afterward.

## Unreal post-import material setup

### JSON sidecar contract

The Blender material JSON sidecar is the source of truth for slot names, slot indexes, material presets, target material instance names, and whether a missing material instance may be created.

The Unreal post-import script reads that sidecar and applies the matching material instance to the imported Static Mesh or Skeletal Mesh slot.

### Hair material instances

Hair materials whose Blender material names normalize to `M_HT...` are treated as existing hair material instances, not as new AssetSurface materials.

For these entries the JSON writer emits:

* `master_preset: "hair"`
* `material_instance_name: "MI_HT..."`
* `create_if_missing: false`
* no texture entries
* no layer entries

The Unreal post-import step then looks for the existing legacy hair material instance set using the normalized hair base name, including common variants such as:

* `HT...`
* `HT..._Inst`
* `HT..._LWHQ_Inst`

When a matching source `MaterialInstanceConstant` is found outside `/Game/Material/AssetSurface/`, it is moved into the managed hair MI folder:

```text
/Game/Material/AssetSurface/MI/Hair/MI_HT...
```

If a wrong generated target already exists there, it is deleted before the legacy hair instance is moved into place. If the correct target already exists and no legacy source remains, the existing target is reused.

If no legacy source and no target can be found, the slot is skipped with a warning. The script does not create a fresh hair material instance in that case.

### Hair non-goals

Hair handoff deliberately does not:

* create a new material instance from the AssetSurface master
* import or assign texture maps from Blender for hair entries
* assign AssetSurface material layers
* route hair through the translucent/glass material branch
* hard-code one exact source asset path

The source lookup is Asset Registry based, class-filtered to `MaterialInstanceConstant`, and excludes existing AssetSurface paths so the migration prefers the original hair material set.

### Regular AssetSurface materials

Non-hair materials continue through the regular sidecar-driven AssetSurface flow:

* resolve or create the configured material instance when allowed
* import textures with the expected compression and virtual texture settings
* assign flat parameters or material layer instances based on the selected preset
* write material assignments back to the imported mesh slots

3 changes: 2 additions & 1 deletion docs/send2ue/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ nav:
- Instance Assets: "./extensions/instance-assets.md"
- Extras:
- Pipeline Menu: "./extras/pipeline-menu.md"
- Unreal Handoff Automation: "./extras/unreal-handoff-automation.md"
- Addon Preferences: "./extras/addon-preferences.md"
- Community Extensions: "./extras/community-extensions.md"
- Trouble Shooting:
- Community Extensions: "./trouble-shooting/faq.md"
- Errors: "./trouble-shooting/errors.md"
- Errors: "./trouble-shooting/errors.md"
13 changes: 9 additions & 4 deletions src/addons/send2ue/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
from .core import formatting, validations, settings, utilities, export, ingest, extension, io

bl_info = {
"name": "Send to Unreal",
"author": "Epic Games Inc (now a community fork)",
"version": (2, 6, 8),
"name": "Send to Unreal (Kelit b5)",
"author": "Epic Games Inc, poly-hammer community fork; Blender 5.x patches by Kelit",
"version": (2, 6, 7),
"blender": (3, 6, 0),
"location": "Header > Pipeline > Send to Unreal",
"description": "Sends an asset to the first open Unreal Editor instance on your machine.",
"description": "Sends an asset to the first open Unreal Editor instance on your machine. Kelit b5 build: adds slotted-action (Blender 5.0+) compatibility on top of Kelit personal fixes.",
"warning": "",
"wiki_url": "https://poly-hammer.github.io/BlenderTools/send2ue",
"category": "Pipeline",
Expand Down Expand Up @@ -69,6 +69,9 @@ def register():
# add an event handler that will run on new file loads
bpy.app.handlers.load_post.append(bpy.app.handlers.persistent(utilities.setup_project))

# keep the unreal mesh folder path in sync after a save (e.g. "Save As" to a new folder)
bpy.app.handlers.save_post.append(bpy.app.handlers.persistent(utilities.sync_unreal_mesh_folder_path))

# add a function to the event timer that will fire after the addon is enabled
bpy.app.timers.register(utilities.addon_enabled, first_interval=0.1)

Expand All @@ -80,6 +83,8 @@ def unregister():
# remove event handlers
if utilities.setup_project in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(utilities.setup_project)
if utilities.sync_unreal_mesh_folder_path in bpy.app.handlers.save_post:
bpy.app.handlers.save_post.remove(utilities.sync_unreal_mesh_folder_path)

try:
# remove the pipeline menu
Expand Down
6 changes: 3 additions & 3 deletions src/addons/send2ue/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os
from enum import Enum

from .pipeline_contract import collection_name


class PreFixToken(Enum):
SOCKET = 'SOCKET'
Expand Down Expand Up @@ -35,7 +37,7 @@ class ToolInfo(Enum):
NAME = 'send2ue'
APP = 'blender'
LABEL = 'Send to Unreal'
EXPORT_COLLECTION = 'Export'
EXPORT_COLLECTION = collection_name('send_to_unreal_export', 'Export')
COLLECTION_NAMES = [EXPORT_COLLECTION]
TEMPLATE_VERSION = 1
FCURVE_FILE = '{file_path}_custom_property_fcurves.json'
Expand Down Expand Up @@ -81,5 +83,3 @@ class PathModes(Enum):

class RegexPresets:
INVALID_NAME_CHARACTERS = r"[^-+\w]+"
INVALID_SOCKET_CHARACTERS = r"[^-+.\w]+"

188 changes: 188 additions & 0 deletions src/addons/send2ue/core/armature_modifier_fix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Copyright Epic Games, Inc. All Rights Reserved.

import bpy
from . import utilities
from ..constants import BlenderTypes, ToolInfo


STATE_KEY = 'send2ue_armature_modifier_fix_state'


def _export_armature_objects():
export_collection = bpy.data.collections.get(ToolInfo.EXPORT_COLLECTION.value)
if not export_collection:
return []
return [
scene_object
for scene_object in export_collection.all_objects
if (
scene_object.type == BlenderTypes.SKELETON
and export_collection in scene_object.users_collection
)
]


def _make_export_armatures_visible(state):
"""
Temporarily unhides armatures that are explicitly in Export.

Send to Unreal collects rigs through visible objects, but artists often hide
armatures in the viewport while working. If a hidden export armature is left
hidden, the skeletal mesh export can lose the intended rig/skeleton context.
"""
for rig_object in _export_armature_objects():
hide_get = rig_object.hide_get()
hide_viewport = rig_object.hide_viewport
if not hide_get and not hide_viewport:
continue
state['visibility'].append((rig_object.name, hide_get, hide_viewport))
rig_object.hide_set(False)
rig_object.hide_viewport = False


def get_top_parent_rig_object(scene_object, rig_objects):
"""
Gets the highest exported armature in an object's parent chain.

A mesh that lives "inside" an armature like this but has no armature modifier
still gets bundled into the skeletal mesh export by ``set_parent_rig_selection``.
Without skin binding the resulting fbx makes Unreal's importer crash, so we
need to detect this case and give it a valid binding before export.

:param object scene_object: A Blender object.
:param list rig_objects: The armature objects that are being exported.
:return object: The highest parent armature object, or None.
"""
rig_objects = set(rig_objects)
top_rig_object = None
parent = scene_object.parent
while parent:
if parent.type == BlenderTypes.SKELETON and parent in rig_objects:
top_rig_object = parent
parent = parent.parent
return top_rig_object


def _get_bind_bone_name(mesh_object, rig_object):
"""
Picks the bone to rigidly bind a mesh to when it has no vertex groups of its own.

Bone-parented meshes follow a specific bone, so we bind to that bone. Object
parented meshes follow the whole armature, so the root deform bone reproduces
that rigid attachment.

:param object mesh_object: A object of type mesh.
:param object rig_object: The parent armature object.
:return str: A bone name, or None if the armature has no bones.
"""
bones = rig_object.data.bones
if not bones:
return None

# a mesh parented directly to a bone follows that bone
if mesh_object.parent_type == 'BONE' and mesh_object.parent_bone in bones:
return mesh_object.parent_bone

deform_bones = [bone for bone in bones if bone.use_deform]
candidate_bones = deform_bones or list(bones)

# prefer a root bone (no parent) so the mesh follows the armature as a whole
for bone in candidate_bones:
if bone.parent is None:
return bone.name
return candidate_bones[0].name


def prepare():
"""
Adds a temporary armature modifier to any export mesh that is parented to an
exported armature but is missing one. Meshes with no vertex groups are also
given a single full-weight group bound to a bone, so the fbx exports with a
valid skin instead of crashing Unreal on import.

Unlike the Hair Tool conversion, this only acts when an armature modifier is
actually missing. Everything it adds is tracked and removed again in cleanup,
so the user's scene is left untouched.
"""
cleanup()

state = {
'modifiers': [], # list of (object_name, modifier_name)
'vertex_groups': [], # list of (object_name, vertex_group_name)
'visibility': [], # list of (object_name, hide_get, hide_viewport)
}
bpy.app.driver_namespace[STATE_KEY] = state

try:
_make_export_armatures_visible(state)

mesh_objects = utilities.get_from_collection(BlenderTypes.MESH)
rig_objects = utilities.get_from_collection(BlenderTypes.SKELETON)
if not rig_objects:
return

for mesh_object in mesh_objects:
# leave meshes that already have an armature modifier alone
if any(modifier.type == 'ARMATURE' for modifier in mesh_object.modifiers):
continue

rig_object = get_top_parent_rig_object(mesh_object, rig_objects)
if not rig_object:
continue

# if the mesh has no weights at all, bind every vertex to a single bone
# so the fbx exporter writes valid skin weights
if not mesh_object.vertex_groups:
bone_name = _get_bind_bone_name(mesh_object, rig_object)
# no bone to bind to means there is nothing we can do to make this a
# valid skin, so leave the mesh untouched rather than add a dangling
# modifier with no weights
if not bone_name:
continue
vertex_group = mesh_object.vertex_groups.new(name=bone_name)
vertex_group.add(
range(len(mesh_object.data.vertices)),
1.0,
'REPLACE',
)
state['vertex_groups'].append((mesh_object.name, vertex_group.name))

armature_modifier = mesh_object.modifiers.new(name='Armature', type='ARMATURE')
armature_modifier.object = rig_object
state['modifiers'].append((mesh_object.name, armature_modifier.name))

except Exception:
cleanup()
raise


def cleanup():
"""
Removes the armature modifiers and vertex groups added by ``prepare``.
"""
state = bpy.app.driver_namespace.pop(STATE_KEY, None)
if not state:
return

for object_name, modifier_name in state.get('modifiers', []):
scene_object = bpy.data.objects.get(object_name)
if not scene_object:
continue
modifier = scene_object.modifiers.get(modifier_name)
if modifier:
scene_object.modifiers.remove(modifier)

for object_name, vertex_group_name in state.get('vertex_groups', []):
scene_object = bpy.data.objects.get(object_name)
if not scene_object:
continue
vertex_group = scene_object.vertex_groups.get(vertex_group_name)
if vertex_group:
scene_object.vertex_groups.remove(vertex_group)

for object_name, hide_get, hide_viewport in state.get('visibility', []):
scene_object = bpy.data.objects.get(object_name)
if not scene_object:
continue
scene_object.hide_set(hide_get)
scene_object.hide_viewport = hide_viewport
Loading