Asset validation

Updated July 2026

Validators are automatic checks that run inside the DCC before an asset is exported. They catch naming, format, and technical problems — a mesh named off-convention, a non-watertight surface, an oversized texture, a vertex skinned to too many bones — at the point where they are cheapest to fix: before the file ever reaches Perforce, let alone Unreal. The Unreal Engine plugin additionally validates names on its side, as a second line of defense.

As an admin you decide which rules run, at what severity, and with what parameters; artists run the checks at export time and act on the results. Editing the rule set is admin-gated.

This page is both a concept guide and a full reference. If you just want to look up a rule, jump to the validator reference.

Anatomy of a validator

Every validator — built-in or custom — is the same shape. It is a small class with a fixed set of metadata fields and one method that does the checking:

FieldMeaning
rule_idStable identifier, dotted and namespaced by area — mesh.watertight, naming.convention, texture.max_resolution. This is what a convention references when it turns a rule on.
nameHuman-readable summary shown in the portal picker and the in-DCC results list.
categoryGrouping used to organize the picker and to drive the Nanite / NonNanite gate — e.g. Mesh, Rig, Nanite.
severityDefault outcome when it fails: error blocks the export; warning is advisory and the artist can continue. A convention can override this per project.
enabledWhether the rule is on out of the box. The baseline ships some rules off (stricter or studio-specific ones) so a default project isn't over-constrained.
asset_typesWhich asset types the rule is relevant to (static_mesh, skeletal_mesh, animation_sequence, texture, hda, vat). Empty means it applies to any type.
paramsTyped, admin-editable knobs — each a {key, type, default, label} (type is int, float, bool, string, or list). max_tris = 50000, target_fps = 30, and so on.

The check itself returns four things: whether it passed, a human message ("Exceeds limit of 50,000 tris"), the list of affected objects (so the panel can point at exactly what's wrong), and an optional auto-fix — a function the artist can invoke from a Fix button to correct the problem and re-run. Rules that can't be safely auto-fixed simply omit it.

"On by default" and "error by default" describe the baseline. The final rule set for a project is whatever its convention resolves to — a convention can enable a rule the baseline ships off, flip a warning to an error, or tighten a parameter. The defaults in the tables below are the starting point, not the last word.

Where rules come from and how they are scoped

Validators live in the project's naming-conventions document. You can attach a rule at three places, and they cascade downward:

  1. Global — every asset in the project.
  2. Asset type — every asset of one type.
  3. Taxonomy node — any node in the arbitrary-depth tree, applying to that node and everything beneath it.

Deeper levels override matching parameters from shallower ones, so a node can tighten a global rule (for example, a stricter texture limit for hero props). DousenCore resolves the effective rule set for every node ahead of time and bakes it into the compiled conventions; clients simply use the deepest matching set.

Texture size limits are validator rules — texture.max_resolution and texture.resolution_power_of_2 — configured in the Validators tab, not in a separate texture setting.

The portal's Validators tab showing an asset-type tree on the left and a per-rule list for static_mesh on the right, with severity dropdowns and inherited/added-here badges.
The Validators tab, where a deeper node's rule can tighten a parameter inherited from a shallower scope.

The validator catalog

The catalog — the set of rules the portal picker can offer — is a two-layer system, not a single list:

  1. A compiled baseline. DousenCore ships with a built-in set of rules (naming, mesh, textures, rig, animation, and more) compiled into the server itself. This is the last-resort backstop if a project has never reported anything else.
  2. A plugin-derived layer, reported per DCC. Each DCC addon (Blender, Maya, 3ds Max, Houdini, Substance Painter/Designer, …) registers its own validator classes locally. On startup — or whenever asked — the addon walks that local registry, including any custom plugins dropped in validators.extra_paths (below), and reports the resulting list to DousenCore.

DousenCore merges the reported layer over the compiled baseline, per tenant. A rule a DCC reports that the baseline doesn't have becomes a first-class catalog entry, complete with typed parameter inputs in the portal's rule picker; a rule a DCC also has natively overrides the baseline's copy. The merged result is what the portal's rule picker renders, and what a DCC falls back to building its own rule set from if compiled conventions aren't reachable. Because the metadata comes straight from the plugin classes, a custom validator you drop in is exactly as first-class as a built-in.

EndpointPurpose
GET /api/validator-catalogThe merged catalog (baseline + reported layer) of available rules and their parameters, used by the portal's rule picker.
POST /api/validator-catalogA DCC addon reports its locally-derived rule list for the tenant. Called by the addon itself, not something an admin calls directly.

Validator reference

Every built-in rule, grouped by category. Columns: the rule_id; its default severity · state (error/warning, on/off out of the box); the asset types it applies to; its parameters with baseline defaults; and what it actually checks.

Naming

The one category that runs everywhere — every mesh-authoring and texturing host reports it. Names are checked against the project's compiled convention templates.

RuleDefaultApplies toParametersChecks
naming.conventionerror · onanyObject and file names begin with the asset-type prefix from the project convention (e.g. SM_ for a static mesh). Fails if the scene is unsaved, or if the filename still contains an unresolved {placeholder}.
naming.no_special_charserror · onanyNames contain only letters, digits, _ and - — no spaces or punctuation. (Maya additionally tolerates its | and : path separators.)

Mesh — general hygiene

Always-applicable geometry checks, independent of the rendering pipeline. The triangle cap here is a generous sanity ceiling; the tighter, pipeline-specific budgets live in the Nanite and NonNanite categories.

RuleDefaultApplies toParametersChecks
mesh.poly_counterror · onstatic_mesh, skeletal_meshmax_tris = 50000Triangle count per mesh is at or below the cap.
mesh.no_ngonswarning · offstatic_mesh, skeletal_meshNo faces with five or more sides.
mesh.no_zero_areaerror · onstatic_mesh, skeletal_meshNo degenerate faces with zero surface area.
mesh.no_nonmanifolderror · onstatic_mesh, skeletal_meshNo non-manifold edges (an edge shared by more than two faces, etc.).

Nanite — dynamic pipeline

Hygiene and density checks for meshes going through UE5's Nanite/Lumen pipeline. Nanite virtualizes geometry (no authored LODs) but demands clean meshes: open edges, flipped normals, floating geometry and slivers show up as pink/red in the Nanite viewport and break Lumen tracing. These run only when the asset's assigned materials allow Nanite — see the Nanite gate below.

RuleDefaultApplies toParametersChecks
mesh.watertighterror · onstatic_meshEvery edge borders exactly two faces — no open boundary edges. Holes render as missing triangles under Nanite and leak light under Lumen.
mesh.consistent_normalserror · onstatic_mesh, skeletal_meshNo flipped or inconsistently-wound faces (detected from edge winding). Flipped faces read as pink in the Nanite viewport.
mesh.no_sliver_triangleswarning · onstatic_mesh, skeletal_meshmin_angle_deg = 5.0No near-degenerate thin triangles — flags any triangle whose smallest corner angle is below the threshold.
mesh.no_loose_geometryerror · onstatic_mesh, skeletal_meshNo loose vertices or wire edges that aren't part of any face — Nanite can't cluster them.
nanite.min_triangleswarning · onstatic_meshmin_tris = 1000Mesh is dense enough that Nanite's fixed per-mesh overhead pays off; below this a traditional mesh is cheaper.
nanite.tri_vert_ratiowarning · onstatic_meshmax_vert_ratio = 0.6Vertex-to-triangle ratio stays efficient. Excess vertices signal UV/normal seams that split geometry and hurt Nanite clustering (guide target ≈ 0.5–0.6).
uvs.min_coveragewarning · onstatic_meshmin_coverage = 0.65UV0 islands fill at least this fraction of the 0–1 space (texel-density efficiency). A mesh with no UVs fails.

NonNanite — traditional pipeline

The counterpart set: when an asset's materials disallow Nanite, these run instead. They encode the traditional baked-LOD budget — a tighter triangle cap, an authored-LOD-chain requirement, a lightmap UV channel, and an overdraw/culling proxy.

RuleDefaultApplies toParametersChecks
nonnanite.max_triangleswarning · onstatic_meshmax_tris = 60000A tighter per-mesh triangle budget than the always-on mesh.poly_count — a rasterized mesh is far more cost-sensitive per triangle than a Nanite one.
nonnanite.lod_chainwarning · onstatic_meshmin_lods = 1, tris_threshold = 20000Meshes above the tri threshold have at least min_lods authored LOD siblings, detected by naming: <base>_LOD1, _LOD2, … Below the threshold, no LODs are required.
nonnanite.lightmap_uvswarning · onstatic_meshA second UV channel (UV1) exists, reserved for the baked lightmap.
nonnanite.max_bounds_ratiowarning · onstatic_meshmax_extent = 50 (m), dense_tris = 100000Flags meshes that are both large (bounding-box diagonal over max_extent, normalized to meters regardless of scene units) and dense (over dense_tris). Either condition alone is fine; only the combination culls poorly without Nanite.

UVs

RuleDefaultApplies toParametersChecks
uvs.in_rangewarning · offstatic_mesh, skeletal_meshUV islands stay within the 0–1 tile.
uvs.no_overlap_lightmapwarning · onstatic_meshNo overlapping UVs in the lightmap channel (overlaps bake as light bleed).

Scene

RuleDefaultApplies toParametersChecks
scene.transforms_appliederror · onstatic_mesh, skeletal_meshObject scale and rotation are frozen (applied), not left on the transform where UE would import them wrong.
scene.no_hidden_objectswarning · onstatic_mesh, skeletal_mesh, animation_sequenceNo hidden objects lurking in the scene that would export unexpectedly.
scene.single_root_skeletalerror · offskeletal_meshA skeletal mesh has exactly one root object.
scene.no_loose_verticeserror · onstatic_mesh, skeletal_meshNo stray loose vertices in the scene.

Materials

RuleDefaultApplies toParametersChecks
materials.no_missing_slotserror · onstatic_mesh, skeletal_meshNo empty or unlinked material slots on the mesh.
materials.expected_countwarning · offstatic_mesh, skeletal_meshmax_materials = 3Material count per mesh stays within the convention.
materials.no_empty_texture_setswarning · ontextureNo texture sets left empty (Substance Painter).

Rig

RuleDefaultApplies toParametersChecks
rig.bone_namingwarning · onskeletal_meshpattern = ^[a-z][a-z0-9_]*(\.[LR]|_[lr])?$Bone names match the regex (lowercase, optional L/R side suffix).
rig.hierarchyerror · onskeletal_meshcheck_symmetry = falseSkeleton hierarchy is clean; optionally verifies left/right symmetry.
rig.bone_countwarning · onskeletal_meshmax_bones = 256Bone count within the engine/project limit.
rig.max_influenceserror · onskeletal_meshmax_influences = 4No vertex is skinned to more than N bones.
rig.unweighted_verticeserror · onskeletal_meshEvery vertex has at least one skin weight.
rig.root_at_originerror · onskeletal_meshThe root bone sits at the world origin.
rig.bind_posewarning · offskeletal_meshSkeleton is in bind pose, with no leftover rotations.
rig.no_negative_scaleerror · onskeletal_meshNo bones with negative scale (which flips winding downstream).

Animation

RuleDefaultApplies toParametersChecks
anim.frame_rangeerror · onanimation_sequenceThe clip has keyframes and a valid frame range.
anim.fps_matcherror · onanimation_sequencetarget_fps = 30Scene FPS matches the project target.
anim.root_motionwarning · offanimation_sequenceexpect_stationary = false, root_bone = rootRoot motion is intentional — flags a moving root when the clip is meant to be stationary, or vice versa.
anim.no_keys_on_frame_zerowarning · onanimation_sequenceNo keyframes on frame 0 — Unreal expects the range to start at 1.
anim.no_scale_keyswarning · onanimation_sequenceallow_root_scale = false, root_bone = rootNo scale keyframes on bones (optionally allowing them on the root).
anim.bone_count_matcherror · offanimation_sequenceexpected_bone_count = 0Animated bone count matches the target skeleton (0 = accept any).

Textures

RuleDefaultApplies toParametersChecks
texture.resolution_power_of_2error · ontextureBoth dimensions are powers of two.
texture.max_resolutionwarning · ontexturemax_resolution = 4096Resolution is within the ceiling.
texture.channel_completenesserror · ontexturerequired_channels = [BaseColor, Normal, Roughness]Every required channel is present in the export set.
texture.channel_gammaerror · ontextureCorrect color space per channel — sRGB for color, linear for data (normal, roughness, …).

Houdini — HDA & VAT

RuleDefaultApplies toParametersChecks
hda.has_definitionerror · onhdaThe selected node is a valid HDA with a definition.
hda.version_setwarning · onhdaThe HDA carries a version string.
hda.has_descriptionwarning · onhdaThe HDA has a user-facing description.
vat.resolution_power_of_2error · onvatThe VAT texture resolution is a power of two.
vat.output_files_existerror · onvatexpected_outputs = [posmap, normalmap, geo_output]Every configured VAT output is present.

Host-specific rules

Some addons ship checks shaped to their host that aren't part of the shared baseline. They report themselves into the catalog on startup exactly like a custom plugin, so they show up in the picker with typed parameters — but they only exist where that host does. A non-exhaustive sample:

HostRules
3ds Maxscene.units (centimetres), scene.frame_rate, scene.no_unused_layers, rig.frozen_transforms, rig.no_bone_scale, animation.range, animation.no_redundant_keys, uvs.has_uvs, uvs.no_overlapping, materials.naming, materials.no_missing
Substance Designergraph.output_completeness, graph.resolution_power_of_2 (max_resolution = 4096, require_square = true), graph.no_unconnected_nodes

DCC coverage

Not every host runs every category — it runs the checks that make sense for the assets it produces. Blender, Maya and 3ds Max are the full mesh-authoring set; the specialised tools cover their own domain. Naming is the one category every host reports.

CategoryBlenderMaya3ds MaxPainterDesignerHoudini
Naming
Mesh hygiene
Nanite
NonNanite
UVs✓*
Scene✓*
Materials✓*
Rig✓*
Animation✓*
Textures
Graph
HDA / VAT

3ds Max shares the same baseline rule ids as Blender and Maya for naming, mesh hygiene, Nanite and NonNanite. In the ✓* categories it instead ships host-shaped rules under its own ids (see host-specific rules) — scene.units rather than scene.transforms_applied, uvs.no_overlapping rather than uvs.no_overlap_lightmap, and so on — so the category is covered but the exact rule set differs. The Unreal plugin separately re-validates asset names on import, as a second line of defense.

The Nanite / NonNanite gate

The Nanite and NonNanite categories are mutually exclusive per asset. Which one runs is resolved automatically from the asset's material assignment in the project's material catalog: if every material assigned to the mesh allows Nanite, the NonNanite category is dropped and only the (looser) Nanite checks run; if any assigned material disallows it, the reverse happens. An asset with no resolvable material assignment defaults to the Nanite set, since those are the original hygiene checks the category split was carved out of.

This is one half of a single Nanite gate that spans DCC and engine: on export the DCC decides which density checks apply from the material-catalog lookup, and on import FAssetSyncManager in the Unreal plugin sets the mesh's NaniteSettings.bEnabled from that same resolution — so the checks an artist saw at export time match the setting the mesh actually lands with.

The material-catalog-driven Nanite gate is implemented and code-complete, but still awaiting a full workstation end-to-end pass before it's considered production-hardened. Expect category boundaries and edge-case behavior (unassigned materials, mixed assignments) to be refined.

Writing a custom validator

Admins can add their own checks by writing a Python validator plugin and pointing the project at the folder that contains it. Set the validators.extra_paths pipeline setting; each entry may be:

A validator is a class that subclasses ValidatorPlugin, sets the same metadata fields the built-ins use (so it appears in the portal picker with typed parameter inputs), and implements one method, validate(). Register it with the @register_plugin decorator. Everything below is imported from dousen.validators:

ImportRole
ValidatorPluginThe base class you subclass.
register_pluginDecorator that adds your class to the registry so it's discovered and reported to the catalog.
CheckResultThe return type — a type alias for (bool, str, list[str], Callable | None). Optional, for the annotation.

validate(self, context, params) must return a 4-tuple, and this is the whole contract:

  1. passed (bool) — True means the check passed; False is how you fail it. Whether a failure surfaces as an error (blocks export) or a warning (advisory) is decided by the rule's severity metadata, not by validate() — you just report pass/fail.
  2. message (str) — a human explanation shown next to the result on failure; "" on pass.
  3. affected_objects (list[str]) — the specific object/face names that failed, so the panel can point at them; [] on pass.
  4. fix_fn (Callable | None) — an optional zero-argument function that corrects the problem. Return it to make the panel render a Fix button that runs it and re-validates; return None when there's no safe automatic fix.

Import DCC modules inside validate() (and inside the fix), never at module top — the class is imported in every host, but its host-native API (bpy, bmesh, pymxs, Maya cmds, …) only exists in the hosts you list in hosts. The first argument is that host's native context object; params is the resolved, admin-configured parameter dict for this asset — read your own keys out of it with their defaults.

PYTHON — a Blender custom validator, with an auto-fixfrom dousen.validators import ValidatorPlugin, CheckResult, register_plugin


@register_plugin
class NoNgonsValidator(ValidatorPlugin):
    rule_id     = "mesh.no_ngons_custom"   # unique, dotted, namespaced
    name        = "No ngon faces"          # shown in the picker + results list
    category    = "Mesh"                   # groups it in the picker
    hosts       = ["blender"]              # which DCCs load it; ["any"] = all
    severity    = "warning"                # default fail level; a convention may override
    enabled     = True                     # on out of the box
    asset_types = ["static_mesh"]          # [] = any type
    params      = [
        {"key": "max_sides", "type": "int", "default": 4, "label": "Max sides"},
    ]

    def validate(self, bpy_context, params) -> CheckResult:
        import bmesh  # DCC import stays inside validate()

        limit = int(params.get("max_sides", 4))
        offenders = []
        for obj in bpy_context.scene.objects:
            if obj.type != "MESH":
                continue
            bm = bmesh.new()
            bm.from_mesh(obj.data)
            ngons = sum(1 for f in bm.faces if len(f.verts) > limit)
            bm.free()
            if ngons:
                offenders.append(f"{obj.name} ({ngons} ngons)")

        if offenders:
            # An auto-fix: triangulate the offending meshes on demand.
            def _fix():
                import bmesh
                for obj in bpy_context.scene.objects:
                    if obj.type != "MESH":
                        continue
                    bm = bmesh.new()
                    bm.from_mesh(obj.data)
                    bmesh.ops.triangulate(bm, faces=bm.faces)
                    bm.to_mesh(obj.data)
                    bm.free()

            return False, f"{len(offenders)} mesh(es) contain ngons", offenders, _fix

        return True, "", [], None   # passed: empty message, no offenders, no fix

If a check can't be auto-fixed safely, return None as the fourth element and the artist fixes it by hand, then re-runs — exactly like the built-in naming checks.

Keep custom validators in Perforce (a //depot/... entry) so every artist gets the same checks the moment they sync — no per-machine setup. On startup each addon walks the discovered paths and reports the plugins it finds to the catalog, so a new rule shows up in the portal picker for admins too.

Sharing one check across several DCCs

The validator above is fully self-contained — it does its own measuring and its own pass/fail decision. That's the right shape for a one-host check. But many rules are conceptually identical everywhere and differ only in how you read the data out of the host: "count triangles, compare to max_tris" is the same logic in Blender, Maya and 3ds Max — only the API call that counts triangles changes.

For those, Dousen's built-ins use a two-layer split (the classic template-method pattern), and custom validators can too. A DCC-agnostic base class owns the metadata and writes validate() once — the comparison, the offender formatting, the 4-tuple. It leaves a single data hook abstract. Each per-DCC subclass then overrides only that hook to return raw measured data, and sets hosts. It never touches the 4-tuple.

This is exactly why the same rule_id (e.g. mesh.poly_count) appears identically across Blender, Maya and 3ds Max in the coverage matrix: one base, one validate(), three different data-fetch bodies.

PYTHON — the base owns validate(); it calls an abstract hookclass BasePolyCountValidator(ValidatorPlugin):
    rule_id     = "mesh.poly_count"        # metadata lives on the base
    name        = "Polygon count within project limit"
    category    = "Mesh"
    severity    = "error"
    asset_types = ["static_mesh", "skeletal_mesh"]

    def get_mesh_tri_counts(self, context, params):
        """DCC-specific hook — subclass returns [(name, tri_count), ...]."""
        raise NotImplementedError

    def validate(self, context, params) -> CheckResult:
        max_tris = int(params.get("max_tris", 50000))
        offenders = []
        for name, tris in self.get_mesh_tri_counts(context, params):   # ← calls the hook
            if tris > max_tris:
                offenders.append(f"{name} ({tris:,} tris)")
        if offenders:
            return False, f"Exceeds limit of {max_tris:,} tris", offenders, None
        return True, "", [], None          # ← the 4-tuple is assembled here, once
PYTHON — the Maya subclass overrides only the data hook@register_plugin
class MayaPolyCountValidator(BasePolyCountValidator):
    hosts = ["maya"]                       # the only metadata it adds

    def get_mesh_tri_counts(self, context, params):
        import maya.cmds as cmds           # host import stays inside the hook
        return [
            (t, cmds.polyEvaluate(t, triangle=True))
            for t in _mesh_transforms()
        ]                                  # returns raw data — NOT the 4-tuple

At run time the runner calls MayaPolyCountValidator.validate(); because the subclass doesn't override it, that resolves to BasePolyCountValidator.validate(), which calls Maya's get_mesh_tri_counts() for the raw counts, applies the max_tris comparison, and returns the tuple. The subclass looks like it "returns a single value" only because the tuple is built one class up the chain — the contract is unchanged.

Each base class defines its own hook, shaped to what that check needs:

Base classAbstract hookSubclass returns
BasePolyCountValidatorget_mesh_tri_counts[(name, tris)]
BaseWatertightValidatorget_mesh_boundary_edge_counts[(name, open_edge_count)]
BaseConsistentNormalsValidatorget_mesh_face_loops[(name, [face_vert_indices])]
BaseNoSliverTrianglesValidatorget_mesh_triangles[(name, [(p0, p1, p2)])]
BaseNonNaniteMaxBoundsRatioValidatorget_mesh_bounds_and_tris (+ scene_unit_scale)[(name, bbox_min, bbox_max, tris)]

Which style to use? If your check is one-host, or the "measure" and "decide" steps are naturally intertwined, subclass ValidatorPlugin directly and return the 4-tuple — it's less indirection. If the exact same decision logic needs to run across two or more DCCs, write a base with an abstract data hook and a thin subclass per host — you write the comparison once and can't let the hosts drift out of agreement.

Both styles produce ordinary ValidatorPlugin subclasses and are reported to the catalog identically — the split is purely a code-reuse convenience, invisible to admins in the portal and to artists in the DCC.

Validation history and project health

Every check an artist runs — pass, warning, or error — is logged to DousenCore, whether or not the export actually goes through. Admins review the results in three places in the web portal:

The portal's Validation History page listing per-run results with the user, pass/warn/error status, and timestamp.
Every run an artist triggers is logged here, whether or not the export went through.
EndpointPurpose
POST /api/projects/:id/validators/logRecords the outcome of a validation run (called by the DCC addons after every run).
GET /api/projects/:id/health-summaryAggregated pass/warn/error trends, changelist activity, and top offenders over a period. Requires a paid plan — the free tier gets a 403.

The per-asset drill-down is a recent addition and still working its way through final review — treat it as available but young. If a link from the asset list doesn't show what you expect, check for a portal update before assuming the run was never logged.

Admin vs. artist

Troubleshooting: an asset fails a validator

A worked example, using Blender's Dousen panel (Maya, 3ds Max, and the other addons follow the same pattern):

  1. The artist clicks Run Validation.

    The panel populates a per-rule list — each entry shows its id, category, severity, message, and any affected objects. Say naming.convention comes back as an error: "Cube.001" doesn't match the project's SM_{asset_name}_{variant} template. A status-bar banner summarizes the run: "Dousen validation: 1 error(s), 2 warning(s)".

  2. The artist fixes the error.

    If the rule ships an auto-fix, a Fix button next to it applies the fix and re-runs validation automatically. Otherwise the artist renames the object by hand and re-runs validation from the panel.

  3. Export is blocked while the error stands.

    Clicking Export & Notify with an unresolved error is refused outright — "Fix 1 validation error(s) before exporting" — and nothing is exported, published to the bridge, or opened as a Perforce changelist.

  4. Warnings don't block, but they do prompt.

    Once the error is gone, if warnings remain (say a texture.max_resolution warning), Export & Notify shows a confirmation dialog instead of exporting immediately — the artist can proceed knowingly or go back and address it first.

  5. The run is logged either way.

    Every run — the failing one and the clean one that followed — is POSTed to the validation log. An admin reviewing the project's validation history later sees both, with no need to ask the artist what happened.