
Want to showcase stunning 3D models of Indian monuments directly in your browser but dread the load times and complexity of heavy 3D files? If you’re a web developer or 3D enthusiast looking to build interactive, lightweight, and fully coded 3D structures, this post is for you. We’ll explore how to create detailed monuments purely through code without relying on external assets or bulky geometry imports.
By the end of this tutorial, you’ll learn to compose complex monuments from basic shapes, add intricate edge details, simulate realistic stone materials using shaders, and apply advanced lighting to bring your models to life. This approach keeps your projects fast, customizable, and accessible, all while delivering visually impressive results that can run smoothly in any modern browser.
Whether you’re building educational apps, cultural showcases, or just want to level up your WebGL skills, this hands-on guide will walk you through every step of crafting interactive 3D Indian monuments using pure code.
🧱 Composing Monuments From Basic Shapes
Building detailed 3D Indian monuments in the browser doesn’t require heavy 3D models or complex assets. Instead, you can think of it like LEGO: stacking and combining simple geometric shapes such as boxes, cylinders, and domes to form intricate structures. This approach keeps your app lightweight and fully dynamic, letting you create or tweak monuments entirely in code.
Why does this matter? Loading large 3D models often means long wait times, heavy bandwidth, and tricky licensing issues. Generating geometry programmatically avoids all that, and lets you customize shapes on the fly—perfect for interactive web experiences.
Using Primitive Shapes as Building Blocks
The foundation is simple shapes:
- Boxes for walls, platforms, and plinths
- Cylinders for columns, towers, and minarets
- Domes created by revolving 2D outlines (lathe technique)
- Extruded shapes to convert arches or custom outlines into 3D slabs
Each shape can be parameterized by size, position, and rotation, then stacked or combined to form the larger monument.
Lathe and Extrude: Going Beyond Simple Shapes
Two powerful geometry techniques add complexity without external models:
- Lathe: Draw half the profile of a dome or arch, then spin it 360° around an axis to create a smooth, rounded 3D form.
- Extrude: Take a 2D outline (like an arch shape) and push it outwards to add depth, turning it into a solid block.
Example: Building a Miniature Monument
Here’s a simple function that builds a monument with a base platform, a cylindrical tower, and a dome on top using Three.js primitives and lathe geometry.
import * as THREE from 'three';
// Helper: Create lathe dome from 2D profile points
function createDome(radius = 5, height = 3, segments = 32) {
// Half-profile of dome (lathe points)
const points = [];
for (let i = 0; i <= segments; i++) {
const y = (height / segments) * i;
const x = Math.sqrt(radius * radius - (radius - y) * (radius - y));
points.push(new THREE.Vector2(x, y));
}
const geometry = new THREE.LatheGeometry(points, segments);
return new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: 0xcccccc }));
}
export function buildMiniMonument() {
const monument = new THREE.Group();
// Base platform - a flat box
const baseGeometry = new THREE.BoxGeometry(12, 2, 12);
const baseMaterial = new THREE.MeshStandardMaterial({ color: 0x8b7d7b });
const base = new THREE.Mesh(baseGeometry, baseMaterial);
base.position.y = 1;
monument.add(base);
// Cylindrical tower
const towerGeometry = new THREE.CylinderGeometry(3, 3, 10, 32);
const towerMaterial = new THREE.MeshStandardMaterial({ color: 0xa0522d });
const tower = new THREE.Mesh(towerGeometry, towerMaterial);
tower.position.y = 7;
monument.add(tower);
// Dome on top using lathe
const dome = createDome(3, 3, 32);
dome.position.y = 12;
monument.add(dome);
return monument;
}
This snippet builds the monument from three parts: a base slab, a tower, and a dome capped on top. You can call buildMiniMonument() inside your scene setup to add it to your Three.js scene.
Building Monuments as Functions
Each monument is a function returning a THREE.Group composed of basic shapes arranged programmatically. This modular approach means you can:
- Reuse shapes across monuments (e.g., a standard dome function)
- Parameterize dimensions and styles
- Stack or nest shapes dynamically based on input
Because you generate all geometry in code, there’s no need to download or parse 3D models, keeping your app fast and dependency-free.
✨ Enhancing Geometry with Edge Crimping and Detail
Basic cylindrical shapes form the backbone of many Indian monuments, but their distinct character comes from the intricate ridges and fluting carved into their surfaces. This section shows how to enhance geometry by crimping edges — pushing vertices in and out along a cylinder’s circumference — to create authentic architectural details like fluted columns or ridged minaret tiers.
Why does this matter? Without these subtle surface variations, models look flat and artificial. Adding edge crimping brings life and texture, making your monument instantly more recognizable and true to its real-world inspiration.
Understanding the Vertex Manipulation
The trick is to adjust each vertex’s position on the cylinder’s circular cross-section based on its angle around the center. Using Math.atan2(z, x) gives the angle a for each vertex. Then, a wave function like cosine creates repeating ridges by scaling the vertex position radially.
For example, multiplying the radius by 1 + amplitude * cos(frequency * a) produces a series of ridges or flutes around the cylinder.
Implementing a Fluted Cylinder Modifier
Let's write a reusable helper function applyFlutes that takes a geometry, number of flutes, amplitude, and frequency to deform the vertices accordingly.
import * as THREE from 'three';
/**
* Applies fluted ridges around the circumference of a cylinder geometry.
* @param geometry THREE.BufferGeometry of a cylinder
* @param fluteCount number of ridges/flutes around the circle
* @param amplitude max radial displacement (e.g., 0.07)
* @param baseRadius original radius of the cylinder
*/
function applyFlutes(
geometry: THREE.BufferGeometry,
fluteCount: number,
amplitude: number,
baseRadius: number
) {
const posAttr = geometry.attributes.position;
const vertexCount = posAttr.count;
for (let i = 0; i < vertexCount; i++) {
const x = posAttr.getX(i);
const z = posAttr.getZ(i);
// Calculate angle around cylinder center
const angle = Math.atan2(z, x);
// Wave function to push vertices in and out
const scale = 1 + amplitude * Math.cos(fluteCount * angle);
// Apply scaled radius to x and z
posAttr.setX(i, x * scale);
posAttr.setZ(i, z * scale);
}
posAttr.needsUpdate = true;
geometry.computeVertexNormals();
}
Using the Fluted Modifier in a Monument Tier
Here’s how to create a tier of a monument with 12 flutes and apply the crimping effect:
const radius = 5;
const height = 3;
const radialSegments = 32;
const heightSegments = 1;
// Create a smooth cylinder for the tier
const tierGeometry = new THREE.CylinderGeometry(radius, radius, height, radialSegments, heightSegments, true);
// Add flutes/ridges: 12 flutes with 7% amplitude
applyFlutes(tierGeometry, 12, 0.07, radius);
const tierMaterial = new THREE.MeshStandardMaterial({ color: 0x8b7d7b, metalness: 0.1, roughness: 0.9 });
const tierMesh = new THREE.Mesh(tierGeometry, tierMaterial);
// Add tierMesh to your scene or monument group
scene.add(tierMesh);
Parameterizing for Different Flute Styles
You can easily tweak the number of flutes (fluteCount) or the amplitude to create different architectural styles. For example, lower storeys might have rounded flutes with fewer waves, while upper storeys can have sharper, star-shaped ridges by increasing frequency or alternating amplitude.
For sharper edges, try replacing Math.cos with Math.abs(Math.cos(...)) or combining multiple waves for complex patterns.
Reusable Helpers for Consistency
Building a library of such geometry modifiers lets you maintain consistent detailing across multiple monuments. You can extend applyFlutes to support vertical variation (e.g., tapering amplitude along height) or combine with other edge effects like chamfers or grooves.
🎨 Painting Realistic Stone and Materials Using Shaders
The biggest challenge in making these monuments look real isn’t the shapes—it’s the surfaces. Plain colors or simple textures make everything feel flat and artificial. But UV unwrapping every piece would be a nightmare for 101 monuments, each made of dozens of parts. The solution? A spatially-aware shader that treats the entire world as one giant block of stone.
This “magic stone” shader lives in world space coordinates, not tied to any individual geometry. It procedurally generates stone patterns, block joints, brick courses, and marble veins seamlessly across all shapes. The result is a cohesive stone texture that feels carved, weathered, and alive without fiddly UV maps.
Implementing the Magic Stone Shader
Here’s a simplified GLSL fragment shader demonstrating the core ideas:
precision highp float;
uniform vec3 uCameraPos;
uniform float uTime;
// World position passed from vertex shader
varying vec3 vWorldPos;
// 3D noise function (simplex or perlin)
float noise3D(vec3 p);
float blockJointPattern(vec3 p) {
// Simulate block joints every 1 unit cube in X and Z
vec2 grid = fract(p.xz);
float jointX = smoothstep(0.0, 0.05, min(grid.x, 1.0 - grid.x));
float jointZ = smoothstep(0.0, 0.05, min(grid.y, 1.0 - grid.y));
return max(jointX, jointZ);
}
float marbleVeins(vec3 p) {
// Marble veins as sine waves with noise modulation
float base = sin(p.y * 10.0 + noise3D(p * 3.0) * 5.0);
return smoothstep(0.6, 0.7, base);
}
float grimeEffect(vec3 p) {
// Darken near ground (y=0) and add streaks
float grime = smoothstep(0.0, 1.0, 1.0 - p.y);
grime += smoothstep(0.0, 0.5, fract(p.y * 10.0));
return clamp(grime, 0.0, 1.0);
}
void main() {
vec3 baseColor = vec3(0.8, 0.78, 0.7); // light stone base
// Block joints darken edges
float joints = blockJointPattern(vWorldPos);
baseColor *= mix(1.0, 0.7, joints);
// Marble veins add subtle white streaks
float veins = marbleVeins(vWorldPos);
baseColor = mix(baseColor, vec3(0.95, 0.95, 0.9), veins * 0.3);
// Fake surface bumps via normal perturbation (not shown here)
// Weathering grime near ground
float grime = grimeEffect(vWorldPos);
baseColor *= mix(1.0, 0.6, grime);
// Random tint per block to avoid monotony
float tintSeed = fract(sin(dot(floor(vWorldPos), vec3(12.9898,78.233, 45.164))) * 43758.5453);
vec3 tint = vec3(0.95 + 0.1 * tintSeed, 0.9 + 0.1 * tintSeed, 0.85 + 0.1 * tintSeed);
baseColor *= tint;
// Output final color
gl_FragColor = vec4(baseColor, 1.0);
}
How This Works
- World-space coordinates: The shader uses the 3D position in the scene, so patterns flow continuously across multiple shapes.
- Block joints: Thin dark lines appear every meter, simulating stone blocks or bricks.
- Marble veins: Sinusoidal noise creates natural vein-like streaks, adding organic detail.
- Weathering: Dark grime builds up near the ground, and faint vertical rain streaks add realism.
- Random tinting: Each block gets a subtle color variation to break uniformity.
Adding Photo Grain for Famous Monuments
For landmarks like the Taj Mahal, we blend in real photo grain textures projected from multiple angles. This adds micro-detail without UV unwraps. The shader samples three grayscale photos from front, side, and top, then tints and blends them into the base stone color. This step is optional but dramatically boosts realism for the best-known sites.
Here’s the conceptual approach:
uniform sampler2D uGrainFront;
uniform sampler2D uGrainSide;
uniform sampler2D uGrainTop;
vec3 applyPhotoGrain(vec3 worldPos, vec3 baseColor) {
vec2 uvFront = projectToUV(worldPos, FRONT_PROJ_MATRIX);
vec2 uvSide = projectToUV(worldPos, SIDE_PROJ_MATRIX);
vec2 uvTop = projectToUV(worldPos, TOP_PROJ_MATRIX);
float grainFront = texture2D(uGrainFront, uvFront).r;
float grainSide = texture2D(uGrainSide, uvSide).r;
float grainTop = texture2D(uGrainTop, uvTop).r;
float grain = (grainFront + grainSide + grainTop) / 3.0;
vec3 tintedGrain = baseColor * grain * 0.15; // subtle effect
return baseColor + tintedGrain;
}
This technique lets you keep your monument code simple while still delivering photo-realistic surface detail.
☀️ Bringing Models to Life with Advanced Lighting Techniques
Lighting is what transforms simple shapes into believable 3D monuments. Without it, your beautifully coded domes and columns look flat and artificial. Proper lighting adds depth, mood, and realism by simulating how light behaves in the real world. This section walks you through setting up a combined lighting environment—day and night—using directional sunlight, skylight, ambient occlusion, reflections, and bloom effects.
Setting Up Natural Daylight with Directional Sunlight and Skylight
The core of natural lighting is a directional light that acts like the sun. It casts soft shadows, giving your model a clear light source and grounding it in space. Complement that with a skylight (or hemisphere light) to gently illuminate shadowed areas, avoiding harsh black spots and keeping the scene balanced.
import * as THREE from 'three';
// Directional sun light with soft shadows
const sunLight = new THREE.DirectionalLight(0xffffff, 1);
sunLight.position.set(100, 200, 100);
sunLight.castShadow = true;
sunLight.shadow.radius = 4; // soft shadow edges
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
// Skylight for ambient sky illumination
const skyLight = new THREE.HemisphereLight(0x87ceeb, 0x444444, 0.6); // sky color, ground color, intensity
scene.add(sunLight, skyLight);
Adding Depth with Ambient Occlusion
Ambient Occlusion (AO) darkens crevices, corners, and undersides where light struggles to reach naturally. It’s a subtle but critical effect for adding realism and volume to your shapes without extra geometry.
In Three.js, you can use a post-processing pass or baked AO maps. For purely coded monuments, screen-space AO (SSAO) is a good choice.
// Example: Adding SSAO pass (using three/examples/jsm/postprocessing/SSAOPass.js)
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composer.addPass(ssaoPass);
// In your render loop:
composer.render();
Reflective Finishes with Environment Maps
Polished marble, bronze, and gold surfaces come alive with reflections. Use environment maps or reflection probes to simulate the surroundings' light bouncing off shiny surfaces. This adds realism by making metals gleam and marble shimmer subtly.
const envMapLoader = new THREE.CubeTextureLoader();
const envMap = envMapLoader.load([
'px.jpg', 'nx.jpg',
'py.jpg', 'ny.jpg',
'pz.jpg', 'nz.jpg'
]);
scene.environment = envMap;
// Apply to a polished material
const polishedMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.7,
roughness: 0.1,
envMap: envMap,
});
Switching to Night Mode: Festival Lights, Floodlights, and Bloom
Night scenes bring out a whole new atmosphere with floodlights, festival lamps, and a starry sky. Add point lights or spotlights with colored glows and combine bloom post-processing to create soft glowing halos around lights.
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
// Floodlight
const floodlight = new THREE.SpotLight(0xffaa33, 2);
floodlight.position.set(50, 100, 50);
floodlight.angle = Math.PI / 6;
floodlight.penumbra = 0.3;
floodlight.castShadow = true;
scene.add(floodlight);
// Festival string lights (small point lights)
const festivalLights = [];
for (let i = 0; i < 10; i++) {
const light = new THREE.PointLight(0xffddaa, 0.5, 10);
light.position.set(Math.sin(i) * 15, 20, Math.cos(i) * 15);
scene.add(light);
festivalLights.push(light);
}
// Bloom pass for glow effect
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, 0.4, 0.85
);
composer.addPass(bloomPass);
Tone Mapping for Balanced Exposure
Tone mapping adjusts the rendered image's exposure to keep bright skies from blowing out and shadows from going pitch black. It balances the overall scene brightness, making lighting feel natural and photographic.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0; // tweak between 0.5 and 2 for best results
Putting It All Together
Here’s a minimal example integrating these elements into a scene setup:
import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 1000);
camera.position.set(50, 50, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
// Lights
const sunLight = new THREE.DirectionalLight(0xffffff, 1);
sunLight.position.set(100, 200, 100);
sunLight.castShadow = true;
sunLight.shadow.radius = 4;
sunLight.shadow.mapSize.set(2048, 2048);
const skyLight = new THREE.HemisphereLight(0x87ceeb, 0x444444, 0.6);
scene.add(sunLight, skyLight);
// Environment map for reflections
const envMapLoader = new THREE.CubeTextureLoader();
const envMap = envMapLoader.load([
'px.jpg', 'nx.jpg',
'py.jpg', 'ny.jpg',
'pz.jpg', 'nz.jpg'
]);
scene.environment = envMap;
// Sample monument geometry (a simple dome + base)
const domeGeometry = new THREE.SphereGeometry(10, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2);
const baseGeometry = new THREE.CylinderGeometry(15, 15, 5, 32);
const polishedMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.7,
roughness: 0.1,
envMap: envMap,
});
const dome = new THREE.Mesh(domeGeometry, polishedMaterial);
dome.position.y = 12;
dome.castShadow = true;
dome.receiveShadow = true;
const base = new THREE.Mesh(baseGeometry, polishedMaterial);
base.receiveShadow = true;
scene.add(base, dome);
// Postprocessing
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composer.addPass(ssaoPass);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
composer.addPass(bloomPass);
function animate() {
requestAnimationFrame(animate);
composer.render();
}
animate();
Common Pitfalls
- Overly harsh shadows: Increase shadow radius or soften shadow maps to avoid unrealistic hard edges.
- AO too strong or weak: Tune SSAO parameters carefully; too much darkening kills detail, too little loses depth.
- Reflection maps missing or low-res: Reflections look fake if environment maps don’t match your scene or are blurry.
- Bloom overuse: Excessive bloom creates a hazy, washed-out look. Use subtle intensity and threshold values.
- Tone mapping off: Without tone mapping, bright skies can clip to white and shadows lose detail.
🧩 Conclusion
Building interactive 3D Indian monuments directly in the browser using pure code showcases the power of geometric composition and shader techniques. By focusing on procedural details and lighting, you can create rich, realistic models without relying on external assets. Next, try experimenting with more complex shapes, dynamic animations, or integrating user interactions to further enhance your 3D experiences.
Key Takeaways
- Complex 3D monuments can be created by composing basic geometric shapes in code.
- Edge crimping adds architectural details like fluting without extra geometry.
- Spatially consistent shaders deliver realistic stone textures without UV mapping.
- Layered lighting effects add depth and realism to purely coded models.
- This method enables lightweight, interactive 3D experiences without external model files.