Overview
This article outlines a method for calculating adaptive texel density in Autodesk Maya using Python. The goal is to scale UV shells based on their distance from a locator, which helps distribute texel resolution more efficiently across a model.
The process uses Maya's Python API (maya.cmds, maya.OpenMaya, and maya.api.OpenMaya) to extract UV shell data, calculate distances in world space, and adjust UV scales accordingly.
What Is Texel Density?
Texel density refers to how many texture pixels (texels) are mapped per unit of world space. It's an important consideration in UV mapping - especially for assets viewed up close, because it affects texture sharpness and consistency.
In most pipelines, texel density is uniform. This approach adjusts it based on spatial relevance: objects closer to a point of interest (e.g. a camera or locator) can receive more texture resolution, while those farther away receive less.
The basic formula for texel density would be:
Texel Density = (Texture Resolution * UV Distance) / World Distance
Where:
Texture Resolution = Number of texels (e.g., 2048 for a 2048x2048 texture).
UV Distance = Measured distance in UV space (typically between two UV points).
World Distance = Measured distance in real-world model space (between the same two points).
Implementation Details
Step 1: Extract UV Shells
UV shells are groups of UVs that form continuous, unbroken patches in UV space. Each shell is treated as a discrete unit for texel density scaling.
def getUvShelList(name):
selList = om.MSelectionList()
selList.add(name)
selListIter = om.MItSelectionList(selList, om.MFn.kMesh)
pathToShape = om.MDagPath()
selListIter.getDagPath(pathToShape)
meshNode = pathToShape.fullPathName()
uvSets = cmds.polyUVSet(meshNode, query=True, allUVSets=True)
allSets = []
for uvset in uvSets:
shapeFn = om.MFnMesh(pathToShape)
shells = om.MScriptUtil()
shells.createFromInt(0)
nbUvShells = shells.asUintPtr()
uArray = om.MFloatArray()
vArray = om.MFloatArray()
uvShellIds = om.MIntArray()
shapeFn.getUVs(uArray, vArray)
shapeFn.getUvShellsIds(uvShellIds, nbUvShells, uvset)
shell_map = {}
for i, n in enumerate(uvShellIds):
shell_map.setdefault(n, []).append(f'{name}.map[{i}]')
allSets.append({uvset: shell_map})
return allSets
Step 2: Compute Shell Centroids
To find the centroid of a UV shell:
1. Sum all the vertex world-space positions.
2. Divide the summed position by the number of vertices.
Formula:
Centroid = (Sum of all vertex positions) / (Number of vertices)
This gives you the average world position (x, y, z) of the shell.
def get_uv_shell_centroids(mesh, shell_select):
cmds.select(shell_select)
shell_vertices = cmds.polyListComponentConversion(toVertex=True)
cmds.select(shell_vertices)
shell_vertices = cmds.ls(shell_vertices, fl=True)
positions = [cmds.pointPosition(v, world=True) for v in shell_vertices]
centroid = [sum(coord) / len(positions) for coord in zip(*positions)]
return centroid
Step 3: Calculate Distance to Locator
The distance between a UV shell centroid and a locator is calculated using the Euclidean distance formula:
Distance = sqrt( (x2 - x1)^2 + (y2 - y1)^2 + (z2 - z1)^2 )
Where:
(x1, y1, z1) is the centroid position.
(x2, y2, z2) is the locator position.
This measures the 3D straight-line distance between the two points.
def calculate_distances_to_locator(centroids, locator_position):
return [ (om2.MVector(c) - om2.MVector(locator_position)).length() for c in centroids ]
Step 4: Normalize Distances
After calculating all distances, we normalize them to fit between 0 and 1. This helps in remapping their influence consistently.
Normalization formula:
Normalized Distance = (Distance - Minimum Distance) / (Maximum Distance - Minimum Distance)
Where:
Distance is the original distance for the current shell.
Minimum Distance is the smallest value in the list of all distances.
Maximum Distance is the largest value.
This ensures the closest shell gets a normalized distance of 0, and the farthest gets 1.
def remap_distances(distances):
min_d = min(distances)
max_d = max(distances)
return [(d - min_d) / (max_d - min_d) for d in distances]
Step 5: Texel Density Calculation
Texel density is based on comparing the UV area and the world-space area of a UV shell.
First, calculate:
Texel Density = (Texture Resolution * sqrt(UV Area)) / sqrt(World Space Area)
Where:
Texture Resolution is typically 2048 (for a 2048x2048 texture).
UV Area is the flattened surface area of the UV shell.
World Space Area is the real-world surface area of the shell's geometry.
We use square roots because UVs and world geometry areas are two-dimensional, and we want a linear ratio.
def get_uv_shell_area():
return sum(cmds.polyEvaluate(uvFaceArea=True))
def get_total_world_space_area():
selected_faces = cmds.ls(selection=True, fl=True)
total_area = 0.0
selection_list = om2.MSelectionList()
for face in selected_faces:
selection_list.clear()
selection_list.add(face)
dag_path, component = selection_list.getComponent(0)
mesh_it = om2.MItMeshPolygon(dag_path, component)
total_area += mesh_it.getArea(om2.MSpace.kWorld)
return total_area
def calculate_texel_density(uv_area, world_area):
return (2048 * math.sqrt(uv_area)) / math.sqrt(world_area)
Step 6: Final UV Scaling
scale_factor = (target_density / current_density) / ((remapped_distance * 3) + 1)
cmds.polyEditUV(value, scaleU=scale_factor, scaleV=scale_factor)
This formula balances texel uniformity and proximity. The closer a shell is to the locator, the higher its texel resolution.
Each UV shell is scaled using both the difference in texel density and its normalized distance to the locator.
Final scaling formula:
UV Scale Factor = (Target Texel Density / Current Texel Density) / ( (Normalized Distance * 3) + 1 )
Where:
Target Texel Density is a user-defined value you want across important areas.
Current Texel Density is the measured density for the shell.
Normalized Distance is the shell's normalized distance to the locator.
The +1 ensures that even shells closest to the locator don't get divided by zero.
Multiplying distance by 3 increases the "falloff" effect: shells further away get scaled down faster.
Full script
import math
import maya.cmds as cmds
import maya.OpenMaya as om
import maya.api.OpenMaya as om2
# Retrieve UV shells for a mesh
def getUvShelList(name):
selList = om.MSelectionList()
selList.add(name)
selListIter = om.MItSelectionList(selList, om.MFn.kMesh)
pathToShape = om.MDagPath()
selListIter.getDagPath(pathToShape)
meshNode = pathToShape.fullPathName()
uvSets = cmds.polyUVSet(meshNode, query=True, allUVSets=True)
allSets = []
for uvset in uvSets:
shapeFn = om.MFnMesh(pathToShape)
shells = om.MScriptUtil()
shells.createFromInt(0)
nbUvShells = shells.asUintPtr()
uArray = om.MFloatArray()
vArray = om.MFloatArray()
uvShellIds = om.MIntArray()
shapeFn.getUVs(uArray, vArray)
shapeFn.getUvShellsIds(uvShellIds, nbUvShells, uvset)
shell_map = {}
for i, n in enumerate(uvShellIds):
shell_map.setdefault(n, []).append(f'{name}.map[{i}]')
allSets.append({uvset: shell_map})
return allSets
# Compute centroid of selected UV shell
def get_uv_shell_centroids(mesh, shell_select):
cmds.select(shell_select)
shell_vertices = cmds.polyListComponentConversion(toVertex=True)
cmds.select(shell_vertices)
shell_vertices = cmds.ls(shell_vertices, fl=True)
positions = [cmds.pointPosition(v, world=True) for v in shell_vertices]
return [sum(coord) / len(positions) for coord in zip(*positions)]
# Compute distances from centroids to locator
def calculate_distances_to_locator(centroids, locator_position):
return [(om2.MVector(c) - om2.MVector(locator_position)).length() for c in centroids]
# Remap distances into a [0, 1] range
def remap_distances(distances):
min_d = min(distances)
max_d = max(distances)
return [(d - min_d) / (max_d - min_d) for d in distances]
# Get summed UV shell area
def get_uv_shell_area():
return sum(cmds.polyEvaluate(uvFaceArea=True))
# Get summed world-space face area
def get_total_world_space_area():
selected_faces = cmds.ls(selection=True, fl=True)
total_area = 0.0
selection_list = om2.MSelectionList()
for face in selected_faces:
selection_list.clear()
selection_list.add(face)
dag_path, component = selection_list.getComponent(0)
mesh_it = om2.MItMeshPolygon(dag_path, component)
total_area += mesh_it.getArea(om2.MSpace.kWorld)
return total_area
# Calculate texel density
def calculate_texel_density(uv_area, world_area):
return (2048 * math.sqrt(uv_area)) / math.sqrt(world_area)
# --- Main Execution ---
mesh = 'Tree'
locator = 'locator1'
target_density = 20.48
shells = getUvShelList(mesh)
uv_chan = shells[0]["UVChannel_1"]
centroids = [get_uv_shell_centroids(mesh, value) for key, value in uv_chan.items()]
locator_position = cmds.xform(locator, q=True, ws=True, t=True)
distances = calculate_distances_to_locator(centroids, locator_position)
remapped_distances = remap_distances(distances)
# Map shell index to its normalized distance
uv_scale_factor_map = {i: d for i, d in enumerate(remapped_distances)}
# Adjust each shell
for key, value in uv_chan.items():
cmds.select(value)
faces = cmds.polyListComponentConversion(toFace=True)
cmds.select(faces)
uv_area = get_uv_shell_area()
world_area = get_total_world_space_area()
current_density = calculate_texel_density(uv_area, world_area)
scale_factor = (target_density / current_density) / ((uv_scale_factor_map[key] * 3) + 1)
cmds.polyEditUV(value, scaleU=scale_factor, scaleV=scale_factor)
Why Does Texel Density Matter?
At first glance, it might seem logical to just use the highest possible texture resolution everywhere and avoid worrying about texel density.
However, in real production environments (games, film, VR, animation), there are major technical and artistic reasons why consistent and controlled texel density matters.
1. Memory and Performance Limits
Textures consume a lot of memory.
A 4K texture uses 4x the memory of a 2K texture.
GPUs and game engines have memory budgets.
Using unnecessarily high-resolution textures everywhere can blow your memory budget very quickly, leading to:
- Lower framerate (performance drops)
- Longer loading times
- Crashes on lower-end hardware (especially consoles, mobile, VR)
Example:
In a typical game scene, using a 4K texture where a 512px would suffice wastes 15x the memory — multiplied across hundreds of assets.
2. Visual Consistency
If one part of a model has high texel density and another has low texel density, the model will look inconsistent:
- Some areas will appear sharp
- Other areas will appear blurry
This is very noticeable and distracting, especially on modular pieces or tiled environments.
Example:
A character's face is sharp, but their hands are blurry because of poor texel density management. It immediately breaks immersion.
3. Efficient Asset Production
If texel density is standardized, artists can:
- Reuse materials across multiple models.
- Bake maps at predictable settings.
- Work faster without worrying that something will "look wrong" later.
It also simplifies LOD (Level of Detail) systems - because you know how much detail you are losing per LOD step.
4. Render Times (for VFX / Film)
In VFX or animated films, textures are sampled at render time.
Oversized textures increase render time dramatically.
Consistency means faster, more predictable renders and fewer mipmapping issues.
Example:
In a production shot, textures that are 8K but only occupy 5% of the frame waste a lot of RAM and slow down render farms. And time is money.
5. Streaming and VR Optimization
In open-world games and VR, assets stream into memory dynamically.
Using unnecessarily large textures hurts streaming performance.
In VR especially, framerate stability is absolutely critical (90+ FPS). Poor texel density management can cause serious hitches or discomfort for users.
Why Not Always Use Maximum Resolution?
Short answer: you can’t afford it — in both performance and production costs.
More precisely:
- It wastes GPU/CPU memory.
- It increases file size and loading times.
- It causes visual inconsistency.
- It hurts optimization systems like LODs and texture streaming.
- It increases rendering cost unnecessarily.
Therefore, we control texel density to balance quality and performance:
- High texel density where the camera matters (face, hands, hero props)
- Lower texel density for background objects or less visible areas
And that's exactly why adaptive texel density is so powerful:
You automatically scale UVs based on importance, not just uniformly.