Textures and surfaces
Encoding and uploading
1. Encode J2C with MCT=0
Encode textures as a raw JPEG 2000 codestream with NO multi-component (colour) transform: MCT=0, 5/3 reversible
wavelet, 6 resolution levels, starting FF 4F. upload_texture refuses anything that does not start FF 4F.
- Learned: a correctly wired, fully served texture rendered flat grey in the viewer. A marker diff against a
texture that rendered showed the only difference was MCT=1 versus MCT=0. Blender's JPEG 2000 exporter forces
MCT=1, and a hand-rolled Pillow
Image.save(format="JPEG2000", ...)also defaults to MCT=1. - See:
tools/make_j2c_pillow.py(mct=0, irreversible=False, num_resolutions=6, no_jp2=True). Do not use the oldertools/make_j2c.pyortools/to_j2c.py(Blender path). CONFLICTS.md records a later note that MCT was a red herring for one flat-colour case; every source agrees MCT=0 is the safe profile. - Diagnostic tell, from instrumenting the server's asset handler: the viewer fetches a texture it can decode in progressively refining byte ranges; a codestream it cannot decode was fetched once to the end and never again.
2. Keep textures square
- Learned: a 512 x 256 RGBA texture (5 resolution levels) showed only grey on a mesh (the birds' wing cards were grey rectangles); the same image stretched to 512 x 512 (6 levels, like every texture that worked) rendered at once.
- Rule: lay out an atlas in fractions of the texture, then resize it square (NEAREST) before encoding.
3. Cache uploads by content hash
A re-upload makes a new asset UUID. Cache the UUID keyed on a hash of the file content, not its modification time.
- See:
tools/skin_cottage.py(assets/tex/_uuids.json).
4. Prove the UV path on a trivial object
tools/make_uv_testrect.py builds a 4 x 2 x 1 orientation box (hue = axis, brightness = sign) whose six faces map
by per-vertex UV into six labelled atlas cells, through exactly the spawn_mesh path real models use. If the box
reads right in the viewer, the UV pipeline is right.
Putting textures on faces
5. Box prim face numbers
0 top, 1 -Y, 2 +X, 3 +Y, 4 -X, 5 bottom, in the prim's own axes. On a side face the image's top is the top of the wall.
- Verified by the inside plaster landing on the inside.
6. Texture a prim per face from a one-shot script
PRIM_TEXTURE, face, uuid, <u_rep, v_rep, 0>, <0, 0, 0>, 0 with repeats worked out from the prim's real size, so a
stone is the same size on every wall. A script that ends with llRemoveInventory(llGetScriptName()) leaves the look
and no script behind. Set the tint to white so the texture shows its own colour.
- See:
tools/skin_cottage.py.
7. A mesh takes one texture at spawn; faces are tinted from LSL
spawn_mesh texture= puts one texture on every face. Per-face COLOUR from LSL after spawn reaches the viewer
(face = submesh). So: a pale texture plus face tints (lacquer red, bronze), or an atlas whose bands are symmetric in
v so it reads right whichever way the viewer counts v.
- Per-face TEXTURE from LSL on a mesh: the sources disagree (see CONFLICTS.md). The builders in this repo do set
PRIM_TEXTUREandPRIM_NORMALper face on meshes from one-shot scripts (for exampletools/grow_menhirs.py). - WorldBridge sets the spawn texture on the shape BEFORE the object is added to the scene, so it rides the
creation update. Its code records that a mesh prim's TextureEntry changed server-side after
AddNewSceneObjectwas never fetched by viewers.
8. Show one cell of an atlas with repeats and offset, not a one-frame animation
A one-frame texture animation (llSetTextureAnim(ANIM_ON | LOOP, face, 4, 2, 4.0, 1.0, ...), meant to hold one
cell) is not held by Firestorm: the face showed the whole atlas. Set the face's repeats and offset instead:
PRIM_TEXTURE, face, tex, <1/cols, 1/rows, 0>, <(c + 0.5)/cols - 0.5, (1 - (r + 0.5)/rows) - 0.5, 0>, 0. Layer a
multi-frame animation on top only where motion is wanted.
- Also learned: before blaming a viewer setting for a texture that does not animate, find something that DOES animate in the same viewer (the flowing water did).
9. Detail that sits at a fixed place on a part belongs in that part's UV frame
A lofted hull reads smooth unless each plank is flat across its width, and a dark hull still hides its laps in the viewer's flat light. Put the lap in the TEXTURE: map each strake to one full v tile (v 0 = lower edge) so a shadow band, a lit chamfer and a row of rivets fall where they belong on every strake. The same for deck planks (u = the plank's whole length, so nail pairs land over the stringers; four plank variants stacked in v) and cask staves (one stave per u tile, joints dark at both edges).
- Rope: a tube whose v runs in metres per lay keeps its twist pitch on long and short spans; a three-strand
texture is
sin(pi * ((3u + v) mod 1)). - See:
tools/grow_boat.py,tools/grow_jetty.py.
10. Draw a card's texture in the card's own proportions
A card's texture cell is square but the card is not (a 0.66 m wing span on 64 px, a 0.2 m chord on 128 px). Draw the shape in the card's coordinates (fill the chord), or it becomes a sliver the alpha mask shreds to specks at any distance.
- Learned: the gulls' wings rendered as specks in the viewer while every offline render passed.
- Check: with the atlas open, does the drawn shape fill the card?
11. Image rows run down, UV v runs up
Build image arrays with y rising, then np.flipud before saving.
- Learned: the ice's dark deep middle landed mirrored until flipped (
tools/build_ice.py).
Growing textures
12. Grow tileable textures instead of using the default plywood
The default plywood under a tint reads as "bare faux woodgrain plywood". Grow tileable textures: FFT-filtered noise
(irfft2 of k^-beta shaped white noise, band-limited by kmin/kmax) is periodic by construction; a toroidal Voronoi
gives rubble stone.
- A bicubic-upscaled random grid "blended with its half shift" is NOT tileable (a hard cross seam).
- Band-limit any noise that feeds a warp or a relief map: noise running to Nyquist makes stones look like felt and edges speckle.
- See:
tools/make_cottage_textures.py,tools/grow_moss.py,tools/build_cairn.py.
13. Set coverage by percentile, and scale features to the texture
Threshold a normalised field at a percentile, not a fixed value: a mixed field's tails are never reached, and a lichen silently vanished. Work out the texel size (at 1.1 m per repeat, 1 px is about 1 mm, so a 3-8 cm lichen rosette is 30-80 px), band-limit to that or it shatters to speckle, and multiply by a low-frequency "where it grows" field or it spreads evenly like sprinkles.
14. A regular grid reads as wallpaper
Coursed rubble grown in the texture wants courses per tile, stones of random length wrapping so it tiles, per-stone tone, pillowed edges, deep joints and a low warp on the joints so they are not ruled lines.
- See:
tools/build_tower_inside.py.
15. Planar mapping magnifies contrast
Planar projection tiles the atlas over the object. For a mottled-noise skin it is indistinguishable from UV mapping, but keep texture contrast LOW and detail FINE: bold warped cracks planar-magnified into op-art. Prefer per-vertex UV mapping for any texture whose placement matters.
Alpha
16. Put change in the tint and the mask cutoff; put variety in the texture (the alpha ladder)
A face's colour and alpha-mask cutoff are scriptable (PRIM_COLOR, PRIM_ALPHA_MODE, face, PRIM_ALPHA_MODE_MASK, cutoff). Bake the variety into the texture: leaves pale and near-neutral (the tint is the
season) and every leaf its OWN alpha on a shuffled ladder (70..245). Raising the cutoff then drops leaves one at a
time from every cluster (a crown that thins, not fades); lowering it makes them arrive (litter that gathers).
- Draw leaves in ascending alpha, so the leaf that lasts longer lies on top, or the first to go leaves a leaf-shaped bite in its neighbour.
- Split what should change at different times into separate faces (at most 8).
- Quantise the cutoff (steps of 5): each distinct value is a distinct material.
- See:
tools/tree_year.py(each species' year and its litter),tools/build_oak.py,tools/grow_beans.py.
17. A world-space field can live in a texture's alpha
Map a texture ONCE over a whole area (u, v = world x, y over a square) and make the alpha ladder a world-space order, and the mask cutoff advances a front across the area. The ice sheet's ladder is true distance from the shore (plus smooth noise), so the ice creeps in from the banks and closes over the deep middle last.
- Rank only the texels that are ever drawn (land texels outside the sheet had taken the first half of the ladder).
- Distance from a boundary: city-block BFS closes a hole as a square; use Euclidean distance.
- See:
tools/build_ice.py, andtools/build_snowcaps.pyandtools/build_icicles.pyfor the same trick.
18. Read a mask cutoff back through its material id
inspect shows only a face's materialID, but the id is a content hash: the same mode and cutoff always give the
same id. Calibrate a table once on a scratch box, then read any face's cutoff off its id. Fast-forward a seasonal
script and read colour and cutoff per face as it runs; that is the proof, not the script compiling.
- See:
tools/mask_cutoffs.py.
Relief, shine and colour under the viewer's light
19. Normal maps from the texture itself
LSL can set a face's normal map (PRIM_NORMAL). Derive it from what the texture already knows (the alpha where a
leaf is, a vein, dark fissures in bark), blur a little, and write a tangent-space map (RGB = (n + 1) / 2; +x right,
+y up, +z out of the face). spawn_mesh normal= wires one at spawn.
- See:
tools/normal_maps.py.
20. Specular: small environment shares in bright rooms
A glossy face with an environment share mirrors the room.
- Learned: a barometer's black steel needle read as polished silver and its dark wood case as pale as the limewashed wall.
- Rule: environment 0-5 for wood and dull metal, about 18 for brass.
- Wet stone:
PRIM_SPECULARwithTEXTURE_BLANK, glossiness about 120 and environment about 35, scaled by wetness; 170 / 60 read brassy at a low sun (weather-and-light.md).
21. Daylight whitens light greys
The viewer whitens a light grey in daylight. Paint pale surfaces darker than they should look.
- Learned: a gull's mantle and a bed of vegetables rendered nearly white in daylight.
22. PBR materials are carried but not scriptable here
This OpenSim carries per-face RenderMaterials to the viewer, but YEngine does not implement PRIM_GLTF_*; setting
them would need a new WorldBridge command. Blinn-Phong (diffuse, normal, specular) was sufficient for this world.