diff --git a/examples/models/models_procedural_decals.c b/examples/models/models_procedural_decals.c new file mode 100644 index 000000000..ee636acff --- /dev/null +++ b/examples/models/models_procedural_decals.c @@ -0,0 +1,504 @@ +/******************************************************************************************* +* +* raylib [models] example - procedural decals +* +* Example demonstrates projecting decals onto a 3D mesh surface by transforming the +* mesh's own triangles into the decal's local space and clipping them against a box, +* using only procedurally generated geometry and textures (no external resource files) +* +* NOTE: This is the same clip-space decal projection idea used by models_decals.c +* (in turn based on three.js' DecalGeometry), applied here to a generated mesh and +* generated textures instead of a loaded model, so the example is fully self-contained +* +* Example complexity rating: [★★★★] 4/4 +* +* Example originally created with raylib 6.0, last time updated with raylib 6.0 +* +* Example contributed by PanicTitan (@PanicTitan) and reviewed by Ramon Santamaria (@raysan5) +* +* Example licensed under an unmodified zlib/libpng license, which is an OSI-certified, +* BSD-like license that allows static linking with closed source software +* +* Copyright (c) 2025 PanicTitan (@PanicTitan) +* +********************************************************************************************/ + +#include "raylib.h" +#define RAYGUI_IMPLEMENTATION +#include "raygui.h" +#include "raymath.h" + +#include // Required for: NULL +#include // Required for: memcpy() +#include // Required for: fabsf(), fminf() + +//-------------------------------------------------------------------------------------- +// Global Definitions +//-------------------------------------------------------------------------------------- +#define MAX_DECALS 256 + +//-------------------------------------------------------------------------------------- +// Types and Structures Definition +//-------------------------------------------------------------------------------------- + +// Growable triangle-soup buffer used while building a clipped decal mesh +typedef struct MeshBuilder { + int vertexCount; + int vertexCapacity; + Vector3 *vertices; + Vector2 *uvs; +} MeshBuilder; + +//------------------------------------------------------------------------------------ +// Module Functions Declaration +//------------------------------------------------------------------------------------ +static void AddTriangleToMeshBuilder(MeshBuilder *mb, Vector3 v0, Vector3 v1, Vector3 v2); +static void FreeMeshBuilder(MeshBuilder *mb); +static Mesh BuildMesh(MeshBuilder *mb); +static Vector3 ClipSegment(Vector3 v0, Vector3 v1, Vector3 p, float s); +static Mesh GenMeshDecal(Model model, Matrix projection, float decalSize, float decalOffset); + +//------------------------------------------------------------------------------------ +// Program main entry point +//------------------------------------------------------------------------------------ +int main(void) +{ + // Initialization + //-------------------------------------------------------------------------------------- + const int screenWidth = 800; + const int screenHeight = 450; + + SetConfigFlags(FLAG_MSAA_4X_HINT); + InitWindow(screenWidth, screenHeight, "raylib [models] example - procedural decals"); + + Camera3D camera = { 0 }; + camera.position = (Vector3){ 0.0f, 2.5f, 5.0f }; + camera.target = (Vector3){ 0.0f, 0.0f, 0.0f }; + camera.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + camera.fovy = 45.0f; + camera.projection = CAMERA_PERSPECTIVE; + + // Target model: a procedurally generated knot with a checkerboard skin + Model model = LoadModelFromMesh(GenMeshTorus(0.8f, 1.8f, 32, 64)); + + Image checkerImage = GenImageChecked(512, 512, 32, 32, LIGHTGRAY, GRAY); + Texture2D modelTexture = LoadTextureFromImage(checkerImage); + UnloadImage(checkerImage); + SetTextureFilter(modelTexture, TEXTURE_FILTER_BILINEAR); + model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = modelTexture; + + BoundingBox modelBBox = GetMeshBoundingBox(model.meshes[0]); + camera.target = Vector3Lerp(modelBBox.min, modelBBox.max, 0.5f); + + float modelSize = fminf(fminf(fabsf(modelBBox.max.x - modelBBox.min.x), + fabsf(modelBBox.max.y - modelBBox.min.y)), fabsf(modelBBox.max.z - modelBBox.min.z)); + + float decalSize = modelSize*0.35f; + float decalOffset = 0.01f; + + // Translucent cube previewing where the next decal will land + Model placementCube = LoadModelFromMesh(GenMeshCube(decalSize, decalSize, decalSize)); + placementCube.materials[0].maps[0].color = LIME; + + // Decal texture: a procedurally drawn target/bullseye, no image file needed + Image decalImage = GenImageColor(128, 128, BLANK); + ImageDrawCircle(&decalImage, 64, 64, 60, RED); + ImageDrawCircle(&decalImage, 64, 64, 45, WHITE); + ImageDrawCircle(&decalImage, 64, 64, 30, RED); + ImageDrawCircle(&decalImage, 64, 64, 15, YELLOW); + + Texture2D decalTexture = LoadTextureFromImage(decalImage); + UnloadImage(decalImage); + SetTextureFilter(decalTexture, TEXTURE_FILTER_BILINEAR); + + Material decalMaterial = LoadMaterialDefault(); + decalMaterial.maps[MATERIAL_MAP_DIFFUSE].texture = decalTexture; + decalMaterial.maps[MATERIAL_MAP_DIFFUSE].color = RAYWHITE; + + bool showModel = true; + Model decals[MAX_DECALS] = { 0 }; + int decalCount = 0; + + SetTargetFPS(60); + //-------------------------------------------------------------------------------------- + + // Main game loop + while (!WindowShouldClose()) // Detect window close button or ESC key + { + // Update + //---------------------------------------------------------------------------------- + if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) UpdateCamera(&camera, CAMERA_THIRD_PERSON); + + // Cast a ray from the mouse and keep the closest point it hits on the model + Ray ray = GetScreenToWorldRay(GetMousePosition(), camera); + RayCollision collision = { 0 }; + + if (GetRayCollisionBox(ray, modelBBox).hit && (decalCount < MAX_DECALS)) + { + for (int m = 0; m < model.meshCount; m++) + { + RayCollision meshHit = GetRayCollisionMesh(ray, model.meshes[m], model.transform); + if (meshHit.hit && (!collision.hit || (meshHit.distance < collision.distance))) collision = meshHit; + } + } + + // Project a new decal at the hit point, facing along the surface normal + if (collision.hit && IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && (decalCount < MAX_DECALS)) + { + Vector3 lookTarget = Vector3Add(collision.point, collision.normal); + Matrix splat = MatrixLookAt(collision.point, lookTarget, (Vector3){ 0.0f, 1.0f, 0.0f }); + splat = MatrixMultiply(splat, MatrixRotateZ(DEG2RAD*(float)GetRandomValue(-180, 180))); + + Mesh decalMesh = GenMeshDecal(model, splat, decalSize, decalOffset); + + if (decalMesh.vertexCount > 0) + { + decals[decalCount] = LoadModelFromMesh(decalMesh); + decals[decalCount].materials[0].maps[0] = decalMaterial.maps[0]; + decalCount++; + } + } + //---------------------------------------------------------------------------------- + + // Draw + //---------------------------------------------------------------------------------- + BeginDrawing(); + + ClearBackground(RAYWHITE); + + BeginMode3D(camera); + + if (showModel) DrawModel(model, Vector3Zero(), 1.0f, WHITE); + + for (int i = 0; i < decalCount; i++) DrawModel(decals[i], Vector3Zero(), 1.0f, WHITE); + + // Preview cube at the surface point currently under the mouse + if (collision.hit) + { + Vector3 lookTarget = Vector3Add(collision.point, collision.normal); + Matrix splat = MatrixLookAt(collision.point, lookTarget, (Vector3){ 0.0f, 1.0f, 0.0f }); + placementCube.transform = MatrixInvert(splat); + DrawModel(placementCube, Vector3Zero(), 1.0f, Fade(WHITE, 0.5f)); + } + + DrawGrid(10, 1.0f); + + EndMode3D(); + + // Vertex/triangle counts: base model vs total once decal geometry is added + int baseVertices = 0, baseTriangles = 0; + for (int i = 0; i < model.meshCount; i++) + { + baseVertices += model.meshes[i].vertexCount; + baseTriangles += model.meshes[i].triangleCount; + } + + int totalVertices = baseVertices, totalTriangles = baseTriangles; + for (int i = 0; i < decalCount; i++) + { + totalVertices += decals[i].meshes[0].vertexCount; + totalTriangles += decals[i].meshes[0].triangleCount; + } + + int statX = screenWidth - 280; + DrawText("Vertices", statX + 90, 10, 10, LIME); + DrawText("Triangles", statX + 180, 10, 10, LIME); + DrawText("Base Model", statX, 25, 10, LIME); + DrawText(TextFormat("%d", baseVertices), statX + 90, 25, 10, LIME); + DrawText(TextFormat("%d", baseTriangles), statX + 180, 25, 10, LIME); + DrawText("TOTAL", statX, 40, 10, LIME); + DrawText(TextFormat("%d", totalVertices), statX + 90, 40, 10, LIME); + DrawText(TextFormat("%d", totalTriangles), statX + 180, 40, 10, LIME); + + if (GuiButton((Rectangle){ 10, screenHeight - 80.0f, 100, 40 }, showModel ? "Hide Model" : "Show Model")) + showModel = !showModel; + + if (GuiButton((Rectangle){ 120, screenHeight - 80.0f, 100, 40 }, "Clear Decals")) + { + for (int i = 0; i < decalCount; i++) UnloadModel(decals[i]); + decalCount = 0; + } + + DrawText("Left click: place decal | Right drag: orbit camera", 10, screenHeight - 25, 10, DARKGRAY); + DrawFPS(10, 10); + + EndDrawing(); + //---------------------------------------------------------------------------------- + } + + // De-Initialization + //-------------------------------------------------------------------------------------- + for (int i = 0; i < decalCount; i++) UnloadModel(decals[i]); + + UnloadModel(placementCube); + UnloadTexture(decalTexture); + UnloadTexture(modelTexture); + UnloadModel(model); + + CloseWindow(); // Close window and OpenGL context + //-------------------------------------------------------------------------------------- + + return 0; +} + +//---------------------------------------------------------------------------------- +// Module Functions Definition +//---------------------------------------------------------------------------------- + +// Appends a triangle to a growable mesh builder buffer, growing it in fixed-size chunks +static void AddTriangleToMeshBuilder(MeshBuilder *mb, Vector3 v0, Vector3 v1, Vector3 v2) +{ + if (mb->vertexCapacity <= (mb->vertexCount + 3)) + { + int newVertexCapacity = (1 + (mb->vertexCapacity/256))*256; + Vector3 *newVertices = (Vector3 *)MemAlloc(newVertexCapacity*sizeof(Vector3)); + + if (mb->vertexCapacity > 0) + { + memcpy(newVertices, mb->vertices, mb->vertexCount*sizeof(Vector3)); + MemFree(mb->vertices); + } + + mb->vertices = newVertices; + mb->vertexCapacity = newVertexCapacity; + } + + int index = mb->vertexCount; + mb->vertexCount += 3; + + mb->vertices[index + 0] = v0; + mb->vertices[index + 1] = v1; + mb->vertices[index + 2] = v2; +} + +// Frees a mesh builder's backing buffers +static void FreeMeshBuilder(MeshBuilder *mb) +{ + if (mb->vertices != NULL) MemFree(mb->vertices); + if (mb->uvs != NULL) MemFree(mb->uvs); + *mb = (MeshBuilder){ 0 }; +} + +// Converts a mesh builder's triangle soup into a standard uploaded raylib Mesh +static Mesh BuildMesh(MeshBuilder *mb) +{ + Mesh outMesh = { 0 }; + + outMesh.vertexCount = mb->vertexCount; + outMesh.triangleCount = mb->vertexCount/3; + outMesh.vertices = (float *)MemAlloc(outMesh.vertexCount*3*sizeof(float)); + if (mb->uvs != NULL) outMesh.texcoords = (float *)MemAlloc(outMesh.vertexCount*2*sizeof(float)); + + for (int i = 0; i < mb->vertexCount; i++) + { + outMesh.vertices[3*i + 0] = mb->vertices[i].x; + outMesh.vertices[3*i + 1] = mb->vertices[i].y; + outMesh.vertices[3*i + 2] = mb->vertices[i].z; + + if (mb->uvs != NULL) + { + outMesh.texcoords[2*i + 0] = mb->uvs[i].x; + outMesh.texcoords[2*i + 1] = mb->uvs[i].y; + } + } + + UploadMesh(&outMesh, false); + + return outMesh; +} + +// Clips the segment [v0, v1] against the plane p.x = s, returning the intersection point +static Vector3 ClipSegment(Vector3 v0, Vector3 v1, Vector3 p, float s) +{ + float d0 = Vector3DotProduct(v0, p) - s; + float d1 = Vector3DotProduct(v1, p) - s; + float t = d0/(d0 - d1); + + return Vector3Lerp(v0, v1, t); +} + +// Builds a decal mesh: the model's triangles are transformed into the decal's local space +// (so the decal sits at the origin, facing +Z) and clipped against a decalSize-sided box, +// following the same clip-space projection idea used by engines' decal systems (and by +// three.js' DecalGeometry, which the technique is commonly traced back to). What's left +// after clipping becomes the decal's own small mesh, UV-mapped from its local coordinates +static Mesh GenMeshDecal(Model model, Matrix projection, float decalSize, float decalOffset) +{ + Matrix invProj = MatrixInvert(projection); + MeshBuilder meshBuilders[2] = { 0 }; + int mbIndex = 0; + + // Gather triangles that land anywhere near the decal box; this is a loose, cheap + // pre-filter meant to skip most of the model, the precise clip happens below + for (int meshIndex = 0; meshIndex < model.meshCount; meshIndex++) + { + Mesh mesh = model.meshes[meshIndex]; + + for (int tri = 0; tri < mesh.triangleCount; tri++) + { + Vector3 vertices[3] = { 0 }; + + if (mesh.indices == NULL) + { + for (int v = 0; v < 3; v++) + { + vertices[v] = (Vector3){ + mesh.vertices[3*3*tri + 3*v + 0], + mesh.vertices[3*3*tri + 3*v + 1], + mesh.vertices[3*3*tri + 3*v + 2] + }; + } + } + else + { + for (int v = 0; v < 3; v++) + { + int idx = mesh.indices[3*tri + v]; + vertices[v] = (Vector3){ + mesh.vertices[3*idx + 0], + mesh.vertices[3*idx + 1], + mesh.vertices[3*idx + 2] + }; + } + } + + int insideCount = 0; + for (int i = 0; i < 3; i++) + { + Vector3 v = Vector3Transform(vertices[i], projection); + if ((fabsf(v.x) < decalSize) || (fabsf(v.y) <= decalSize) || (fabsf(v.z) <= decalSize)) insideCount++; + vertices[i] = v; + } + + if (insideCount > 0) AddTriangleToMeshBuilder(&meshBuilders[mbIndex], vertices[0], vertices[1], vertices[2]); + } + } + + // Clip the surviving triangles against each of the decal box's 6 faces in turn + Vector3 planes[6] = { + { 1, 0, 0 }, { -1, 0, 0 }, + { 0, 1, 0 }, { 0, -1, 0 }, + { 0, 0, 1 }, { 0, 0, -1 } + }; + + for (int face = 0; face < 6; face++) + { + mbIndex = 1 - mbIndex; + + MeshBuilder *inMesh = &meshBuilders[1 - mbIndex]; + MeshBuilder *outMesh = &meshBuilders[mbIndex]; + + outMesh->vertexCount = 0; + float s = 0.5f*decalSize; + + for (int i = 0; i < inMesh->vertexCount; i += 3) + { + Vector3 nV1, nV2, nV3, nV4; + + float d1 = Vector3DotProduct(inMesh->vertices[i + 0], planes[face]) - s; + float d2 = Vector3DotProduct(inMesh->vertices[i + 1], planes[face]) - s; + float d3 = Vector3DotProduct(inMesh->vertices[i + 2], planes[face]) - s; + + int v1Out = (d1 > 0); + int v2Out = (d2 > 0); + int v3Out = (d3 > 0); + int total = v1Out + v2Out + v3Out; + + switch (total) + { + case 0: + // Whole triangle is inside this face, keep it as-is + AddTriangleToMeshBuilder(outMesh, inMesh->vertices[i], inMesh->vertices[i + 1], inMesh->vertices[i + 2]); + break; + case 1: + // One corner is outside; clip it off, turning the triangle into a quad + if (v1Out) + { + nV1 = inMesh->vertices[i + 1]; + nV2 = inMesh->vertices[i + 2]; + nV3 = ClipSegment(inMesh->vertices[i], nV1, planes[face], s); + nV4 = ClipSegment(inMesh->vertices[i], nV2, planes[face], s); + } + + if (v2Out) + { + nV1 = inMesh->vertices[i]; + nV2 = inMesh->vertices[i + 2]; + nV3 = ClipSegment(inMesh->vertices[i + 1], nV1, planes[face], s); + nV4 = ClipSegment(inMesh->vertices[i + 1], nV2, planes[face], s); + + AddTriangleToMeshBuilder(outMesh, nV3, nV2, nV1); + AddTriangleToMeshBuilder(outMesh, nV2, nV3, nV4); + break; + } + + if (v3Out) + { + nV1 = inMesh->vertices[i]; + nV2 = inMesh->vertices[i + 1]; + nV3 = ClipSegment(inMesh->vertices[i + 2], nV1, planes[face], s); + nV4 = ClipSegment(inMesh->vertices[i + 2], nV2, planes[face], s); + } + + AddTriangleToMeshBuilder(outMesh, nV1, nV2, nV3); + AddTriangleToMeshBuilder(outMesh, nV4, nV3, nV2); + break; + case 2: + // Two corners are outside; only the small corner near the surviving vertex remains + if (!v1Out) + { + nV1 = inMesh->vertices[i]; + nV2 = ClipSegment(nV1, inMesh->vertices[i + 1], planes[face], s); + nV3 = ClipSegment(nV1, inMesh->vertices[i + 2], planes[face], s); + AddTriangleToMeshBuilder(outMesh, nV1, nV2, nV3); + } + + if (!v2Out) + { + nV1 = inMesh->vertices[i + 1]; + nV2 = ClipSegment(nV1, inMesh->vertices[i + 2], planes[face], s); + nV3 = ClipSegment(nV1, inMesh->vertices[i], planes[face], s); + AddTriangleToMeshBuilder(outMesh, nV1, nV2, nV3); + } + + if (!v3Out) + { + nV1 = inMesh->vertices[i + 2]; + nV2 = ClipSegment(nV1, inMesh->vertices[i], planes[face], s); + nV3 = ClipSegment(nV1, inMesh->vertices[i + 1], planes[face], s); + AddTriangleToMeshBuilder(outMesh, nV1, nV2, nV3); + } + break; + default: + // All 3 corners are outside this face, the triangle is fully clipped away + break; + } + } + } + + MeshBuilder *finalMesh = &meshBuilders[mbIndex]; + Mesh decalMesh = { 0 }; + + if (finalMesh->vertexCount > 0) + { + finalMesh->uvs = (Vector2 *)MemAlloc(sizeof(Vector2)*finalMesh->vertexCount); + + for (int i = 0; i < finalMesh->vertexCount; i++) + { + // Clipped coordinates run roughly (-decalSize/2 .. decalSize/2); remap to (0..1) + finalMesh->uvs[i].x = (finalMesh->vertices[i].x/decalSize + 0.5f); + finalMesh->uvs[i].y = (finalMesh->vertices[i].y/decalSize + 0.5f); + + // Nudge slightly along the normal so the decal doesn't z-fight with the surface + finalMesh->vertices[i].z -= decalOffset; + finalMesh->vertices[i] = Vector3Transform(finalMesh->vertices[i], invProj); + } + + decalMesh = BuildMesh(finalMesh); + } + + FreeMeshBuilder(&meshBuilders[0]); + FreeMeshBuilder(&meshBuilders[1]); + + return decalMesh; +} diff --git a/examples/models/models_procedural_decals.png b/examples/models/models_procedural_decals.png new file mode 100644 index 000000000..1d31a2b1c Binary files /dev/null and b/examples/models/models_procedural_decals.png differ