Add procedural lightmap system for planets and asteroids
Some checks failed
Build / build (push) Failing after 19s

- Create sphereLightmap.ts for procedural lighting generation
- Update planets to use lightmaps oriented toward sun
- Switch asteroids to PBR material with noise texture
- Use sphere physics shape for asteroids

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-10-29 17:30:02 -05:00
parent 942c0a1af0
commit a9054c2389
7 changed files with 241 additions and 63 deletions

BIN
public/ship1.blend Normal file

Binary file not shown.

BIN
public/ship1.glb Normal file

Binary file not shown.

View File

@ -26,6 +26,7 @@ import {
validateLevelConfig validateLevelConfig
} from "./levelConfig"; } from "./levelConfig";
import { FireProceduralTexture } from "@babylonjs/procedural-textures"; import { FireProceduralTexture } from "@babylonjs/procedural-textures";
import {createSphereLightmap} from "./sphereLightmap";
/** /**
* Deserializes a LevelConfig JSON object and creates all entities in the scene * Deserializes a LevelConfig JSON object and creates all entities in the scene
@ -59,7 +60,7 @@ export class LevelDeserializer {
const startBase = this.createStartBase(); const startBase = this.createStartBase();
const sun = this.createSun(); const sun = this.createSun();
const planets = this.createPlanets(); const planets = this.createPlanets();
const asteroids = await this.createAsteroids(startBase, scoreObservable); const asteroids = await this.createAsteroids(scoreObservable);
return { return {
startBase, startBase,
@ -104,8 +105,8 @@ export class LevelDeserializer {
const config = this.config.sun; const config = this.config.sun;
// Create point light // Create point light
const light = new PointLight("light", this.arrayToVector3(config.position), this.scene); //const light = new PointLight("light", this.arrayToVector3(config.position), this.scene);
light.intensity = config.intensity || 1000000; //light.intensity = config.intensity || 1000000;
// Create sun sphere // Create sun sphere
const sun = MeshBuilder.CreateSphere("sun", { const sun = MeshBuilder.CreateSphere("sun", {
@ -123,8 +124,8 @@ export class LevelDeserializer {
sun.material = material; sun.material = material;
// Create glow layer // Create glow layer
const gl = new GlowLayer("glow", this.scene); //const gl = new GlowLayer("glow", this.scene);
gl.intensity = 1; //gl.intensity = 1;
return sun; return sun;
} }
@ -134,6 +135,7 @@ export class LevelDeserializer {
*/ */
private createPlanets(): AbstractMesh[] { private createPlanets(): AbstractMesh[] {
const planets: AbstractMesh[] = []; const planets: AbstractMesh[] = [];
const sunPosition = this.arrayToVector3(this.config.sun.position);
for (const planetConfig of this.config.planets) { for (const planetConfig of this.config.planets) {
const planet = MeshBuilder.CreateSphere(planetConfig.name, { const planet = MeshBuilder.CreateSphere(planetConfig.name, {
@ -141,17 +143,37 @@ export class LevelDeserializer {
segments: 32 segments: 32
}, this.scene); }, this.scene);
planet.position = this.arrayToVector3(planetConfig.position); const planetPosition = this.arrayToVector3(planetConfig.position);
planet.position = planetPosition;
if (planetConfig.rotation) { // Calculate direction from planet to sun
planet.rotation = this.arrayToVector3(planetConfig.rotation); const toSun = sunPosition.subtract(planetPosition).normalize();
}
// Apply texture // Apply texture
const material = new StandardMaterial(planetConfig.name + "-material", this.scene); const material = new StandardMaterial(planetConfig.name + "-material", this.scene);
const texture = new Texture(planetConfig.texturePath, this.scene); const texture = new Texture(planetConfig.texturePath, this.scene);
material.diffuseTexture = texture;
material.ambientTexture = texture; // Create lightmap with bright light pointing toward sun
const lightmap = createSphereLightmap(
planetConfig.name + "-lightmap",
512, // texture size
DefaultScene.MainScene,
toSun, // bright light from sun direction
1, // bright intensity
toSun.negate(), // dim light from opposite direction
0.3, // dim intensity
0.3 // ambient
);
// Apply to material
// Use emissiveTexture (self-lit) instead of diffuseTexture when lighting is disabled
material.emissiveTexture = texture;
material.lightmapTexture = lightmap;
material.useLightmapAsShadowmap = true;
// Disable standard lighting since we're using baked lightmap
material.disableLighting = true;
material.roughness = 1; material.roughness = 1;
material.specularColor = Color3.Black(); material.specularColor = Color3.Black();
planet.material = material; planet.material = material;
@ -167,7 +189,6 @@ export class LevelDeserializer {
* Create asteroids from config * Create asteroids from config
*/ */
private async createAsteroids( private async createAsteroids(
startBase: AbstractMesh,
scoreObservable: Observable<ScoreEvent> scoreObservable: Observable<ScoreEvent>
): Promise<AbstractMesh[]> { ): Promise<AbstractMesh[]> {
const asteroids: AbstractMesh[] = []; const asteroids: AbstractMesh[] = [];

View File

@ -1,4 +1,4 @@
import type {AudioEngineV2} from "@babylonjs/core"; import {AudioEngineV2, DirectionalLight} from "@babylonjs/core";
import { import {
Color3, Color3,
CreateAudioEngineAsync, CreateAudioEngineAsync,
@ -170,9 +170,10 @@ export class Main {
private async setupPhysics() { private async setupPhysics() {
const havok = await HavokPhysics(); const havok = await HavokPhysics();
const havokPlugin = new HavokPlugin(true, havok); const havokPlugin = new HavokPlugin(true, havok);
DefaultScene.MainScene.ambientColor = new Color3(.1, .1, .1);
const light = new DirectionalLight("dirLight", new Vector3(-1, -2, -1), DefaultScene.MainScene);
DefaultScene.MainScene.enablePhysics(new Vector3(0, 0, 0), havokPlugin); DefaultScene.MainScene.enablePhysics(new Vector3(0, 0, 0), havokPlugin);
DefaultScene.MainScene.getPhysicsEngine().setTimeStep(1/45); DefaultScene.MainScene.getPhysicsEngine().setTimeStep(1/30);
DefaultScene.MainScene.getPhysicsEngine().setSubTimeStep(5); DefaultScene.MainScene.getPhysicsEngine().setSubTimeStep(5);
DefaultScene.MainScene.collisionsEnabled = true; DefaultScene.MainScene.collisionsEnabled = true;

View File

@ -1,5 +1,5 @@
import { import {
AbstractMesh, AbstractMesh, Angle,
Color3, Color3,
DirectionalLight, DirectionalLight,
FreeCamera, FreeCamera,
@ -103,7 +103,7 @@ export class Ship {
ammo.position.y = 2; ammo.position.y = 2;
ammo.rotation.x = Math.PI / 2; ammo.rotation.x = Math.PI / 2;
ammo.setParent(null); ammo.setParent(null);
const ammoAggregate = new PhysicsAggregate(ammo, PhysicsShapeType.CONVEX_HULL, { const ammoAggregate = new PhysicsAggregate(ammo, PhysicsShapeType.SPHERE, {
mass: 1000, mass: 1000,
restitution: 0 restitution: 0
}, DefaultScene.MainScene); }, DefaultScene.MainScene);
@ -118,7 +118,7 @@ export class Ship {
window.setTimeout(() => { window.setTimeout(() => {
ammoAggregate.dispose(); ammoAggregate.dispose();
ammo.dispose() ammo.dispose()
}, 1500); }, 2000);
} }
public set position(newPosition: Vector3) { public set position(newPosition: Vector3) {
@ -141,7 +141,7 @@ export class Ship {
} }
this._ammoMaterial = new StandardMaterial("ammoMaterial", DefaultScene.MainScene); this._ammoMaterial = new StandardMaterial("ammoMaterial", DefaultScene.MainScene);
this._ammoMaterial.emissiveColor = new Color3(1, 1, 0); this._ammoMaterial.emissiveColor = new Color3(1, 1, 0);
this._ammoBaseMesh = MeshBuilder.CreateCapsule("bullet", {radius: .1, height: 2.5}, DefaultScene.MainScene); this._ammoBaseMesh = MeshBuilder.CreateSphere("bullet", {diameter: .2}, DefaultScene.MainScene);
this._ammoBaseMesh.material = this._ammoMaterial; this._ammoBaseMesh.material = this._ammoMaterial;
this._ammoBaseMesh.setEnabled(false); this._ammoBaseMesh.setEnabled(false);
@ -176,33 +176,37 @@ export class Ship {
DefaultScene.MainScene.setActiveCameraByName("Flat Camera"); DefaultScene.MainScene.setActiveCameraByName("Flat Camera");
//const sightPos = this._forwardNode.position.scale(30); //const sightPos = this._forwardNode.position.scale(30);
const sight = MeshBuilder.CreateSphere("sight", {diameter: 1}, DefaultScene.MainScene); const sight = MeshBuilder.CreateDisc("sight", {radius: 2 }, DefaultScene.MainScene);
sight.parent = this._ship sight.parent = this._ship
//sight.rotation.x = -Math.PI / 2;
const signtMaterial = new StandardMaterial("sightMaterial", DefaultScene.MainScene); const signtMaterial = new StandardMaterial("sightMaterial", DefaultScene.MainScene);
signtMaterial.emissiveColor = Color3.Yellow(); signtMaterial.emissiveColor = Color3.Yellow();
signtMaterial.ambientColor = Color3.Yellow(); signtMaterial.ambientColor = Color3.Yellow();
sight.material = signtMaterial; sight.material = signtMaterial;
sight.position = new Vector3(0, 2, 125); sight.position = new Vector3(0, 2, 125);
let i = Date.now(); sight.renderingGroupId = 3;
let i = 0;
DefaultScene.MainScene.onBeforeRenderObservable.add(() => { DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
if (Date.now() - i > 50 && this._active == true) { if (i++ % 10 == 0) {
this.applyForce(); this.applyForce();
i = Date.now();
} }
}); });
this._active = true; this._active = true;
} }
private async initialize() { private async initialize() {
const importMesh = await SceneLoader.ImportMeshAsync(null, "./", "cockpit2.glb", DefaultScene.MainScene); const importMesh = await SceneLoader.ImportMeshAsync(null, "./", "ship1.glb", DefaultScene.MainScene);
const shipMesh = importMesh.meshes[0]; const shipMesh = importMesh.meshes[0];
shipMesh.id = "shipMesh"; shipMesh.id = "shipMesh";
shipMesh.name = "shipMesh"; shipMesh.name = "shipMesh";
shipMesh.parent = this._ship; shipMesh.parent = this._ship;
shipMesh.rotation.y = Math.PI; //shipMesh.rotation.y = Angle.FromDegrees(90).radians();
shipMesh.position.y = 1; //shipMesh.rotation.y = Math.PI;
//shipMesh.position.y = 1;
shipMesh.position.z = -1; shipMesh.position.z = -1;
shipMesh.renderingGroupId = 3; shipMesh.renderingGroupId = 3;
const light = new PointLight("ship.light", new Vector3(0, 1, .9), DefaultScene.MainScene); const light = new PointLight("ship.light", new Vector3(0, .5, .1), DefaultScene.MainScene);
light.intensity = 4; light.intensity = 4;
light.includedOnlyMeshes = [shipMesh]; light.includedOnlyMeshes = [shipMesh];
for (const mesh of shipMesh.getChildMeshes()) { for (const mesh of shipMesh.getChildMeshes()) {
@ -212,7 +216,7 @@ export class Ship {
} }
} }
light.parent = this._ship; light.parent = this._ship;
DefaultScene.MainScene.getMaterialById('glass_mat.002').alpha = .4; //DefaultScene.MainScene.getMaterialById('glass_mat.002').alpha = .4;
} }

148
src/sphereLightmap.ts Normal file
View File

@ -0,0 +1,148 @@
import { DynamicTexture, Scene, Vector3 } from "@babylonjs/core";
/**
* Generate a lightmap texture for a sphere with two directional lights
* @param name - Texture name
* @param size - Texture resolution (e.g., 512, 1024)
* @param scene - Babylon scene
* @param brightLightDir - Direction of bright light (will be normalized)
* @param brightIntensity - Intensity of bright light (0-1)
* @param dimLightDir - Direction of dim light (will be normalized)
* @param dimIntensity - Intensity of dim light (0-1)
* @param ambientIntensity - Base ambient light (0-1)
* @returns DynamicTexture with baked lighting
*/
export function createSphereLightmap(
name: string,
size: number,
scene: Scene,
brightLightDir: Vector3 = new Vector3(1, 0, 0),
brightIntensity: number = 1.0,
dimLightDir: Vector3 = new Vector3(-1, 0, 0),
dimIntensity: number = 0.2,
ambientIntensity: number = 0.1
): DynamicTexture {
const texture = new DynamicTexture(name, { width: size, height: size }, scene, false);
const context = texture.getContext();
const imageData = context.createImageData(size, size);
// Normalize light directions
const brightDir = brightLightDir.normalize();
const dimDir = dimLightDir.normalize();
// Generate lightmap
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// Convert pixel coordinates to UV (0-1)
const u = x / (size - 1);
const v = y / (size - 1);
// Convert UV to 3D position on unit sphere
// Using spherical coordinates: theta (longitude), phi (latitude)
const theta = u * Math.PI * 2; // 0 to 2π
const phi = v * Math.PI; // 0 to π
// Convert spherical to Cartesian (unit sphere)
const normal = new Vector3(
Math.sin(phi) * Math.cos(theta),
Math.cos(phi),
Math.sin(phi) * Math.sin(theta)
);
// Calculate lighting from bright light
// Lambertian diffuse: max(0, dot(normal, lightDir))
const brightDot = Vector3.Dot(normal, brightDir);
const brightLight = Math.max(0, brightDot) * brightIntensity;
// Calculate lighting from dim light
const dimDot = Vector3.Dot(normal, dimDir);
const dimLight = Math.max(0, dimDot) * dimIntensity;
// Combine all lighting
const totalLight = ambientIntensity + brightLight + dimLight;
// Clamp to 0-1 range
const intensity = Math.min(1, Math.max(0, totalLight));
// Convert to 0-255 grayscale
const brightness = Math.floor(intensity * 255);
// Set pixel (RGBA)
const index = (y * size + x) * 4;
imageData.data[index + 0] = brightness; // R
imageData.data[index + 1] = brightness; // G
imageData.data[index + 2] = brightness; // B
imageData.data[index + 3] = 255; // A (fully opaque)
}
}
// Write image data to texture
context.putImageData(imageData, 0, 0);
texture.update();
return texture;
}
/**
* Create a colored lightmap with tinted lights
*/
export function createColoredSphereLightmap(
name: string,
size: number,
scene: Scene,
brightLightDir: Vector3 = new Vector3(1, 0, 0),
brightColor: { r: number; g: number; b: number } = { r: 1, g: 1, b: 0.8 },
brightIntensity: number = 1.0,
dimLightDir: Vector3 = new Vector3(-1, 0, 0),
dimColor: { r: number; g: number; b: number } = { r: 0.3, g: 0.3, b: 0.5 },
dimIntensity: number = 0.2,
ambientColor: { r: number; g: number; b: number } = { r: 0.1, g: 0.1, b: 0.1 }
): DynamicTexture {
const texture = new DynamicTexture(name, { width: size, height: size }, scene, false);
const context = texture.getContext();
const imageData = context.createImageData(size, size);
const brightDir = brightLightDir.normalize();
const dimDir = dimLightDir.normalize();
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const u = x / (size - 1);
const v = y / (size - 1);
const theta = u * Math.PI * 2;
const phi = v * Math.PI;
const normal = new Vector3(
Math.sin(phi) * Math.cos(theta),
Math.cos(phi),
Math.sin(phi) * Math.sin(theta)
);
// Calculate lighting from each source
const brightDot = Math.max(0, Vector3.Dot(normal, brightDir)) * brightIntensity;
const dimDot = Math.max(0, Vector3.Dot(normal, dimDir)) * dimIntensity;
// Combine colored lights
const r = ambientColor.r + (brightColor.r * brightDot) + (dimColor.r * dimDot);
const g = ambientColor.g + (brightColor.g * brightDot) + (dimColor.g * dimDot);
const b = ambientColor.b + (brightColor.b * brightDot) + (dimColor.b * dimDot);
// Clamp and convert to 0-255
const red = Math.floor(Math.min(1, Math.max(0, r)) * 255);
const green = Math.floor(Math.min(1, Math.max(0, g)) * 255);
const blue = Math.floor(Math.min(1, Math.max(0, b)) * 255);
const index = (y * size + x) * 4;
imageData.data[index + 0] = red;
imageData.data[index + 1] = green;
imageData.data[index + 2] = blue;
imageData.data[index + 3] = 255;
}
}
context.putImageData(imageData, 0, 0);
texture.update();
return texture;
}

View File

@ -2,7 +2,7 @@ import {
AbstractMesh, AbstractMesh,
Color3, InstancedMesh, Color3, InstancedMesh,
Mesh, Mesh,
MeshBuilder, Observable, MeshBuilder, NoiseProceduralTexture, Observable,
ParticleHelper, ParticleHelper,
ParticleSystem, ParticleSystem,
ParticleSystemSet, ParticleSystemSet,
@ -10,12 +10,13 @@ import {
PhysicsAggregate, PhysicsBody, PhysicsAggregate, PhysicsBody,
PhysicsMotionType, PhysicsMotionType,
PhysicsShapeType, PhysicsViewer, PhysicsShapeType, PhysicsViewer,
SceneLoader, SceneLoader, StandardMaterial,
Vector3 Vector3
} from "@babylonjs/core"; } from "@babylonjs/core";
import {DefaultScene} from "./defaultScene"; import {DefaultScene} from "./defaultScene";
import {ScoreEvent} from "./scoreboard"; import {ScoreEvent} from "./scoreboard";
import {Debug} from "@babylonjs/core/Legacy/legacy"; import {Debug} from "@babylonjs/core/Legacy/legacy";
import {createSphereLightmap} from "./sphereLightmap";
let _particleData: any = null; let _particleData: any = null;
export class Rock { export class Rock {
@ -50,35 +51,35 @@ export class RockFactory {
console.log(`Created ${this._poolSize} explosion particle systems in pool`); console.log(`Created ${this._poolSize} explosion particle systems in pool`);
if (!this._rockMesh) { if (!this._rockMesh) {
console.log('loading mesh'); await this.loadMesh();
const importMesh = await SceneLoader.ImportMeshAsync(null, "./", "asteroid2.glb", DefaultScene.MainScene);
this._rockMesh = importMesh.meshes[1].clone("asteroid", null, false);
this._rockMesh.setParent(null);
this._rockMesh.setEnabled(false);
//importMesh.meshes[1].dispose();
console.log(importMesh.meshes);
if (!this._rockMaterial) {
this._rockMaterial = this._rockMesh.material.clone("asteroid") as PBRMaterial;
this._rockMaterial.name = 'asteroid-material';
this._rockMaterial.id = 'asteroid-material';
const material = (this._rockMaterial as PBRMaterial)
//material.albedoTexture = null;
//material.ambientColor = new Color3(.4, .4 ,.4);
material.albedoColor = new Color3(.4, .4 ,.4);
//material.ambientTexture = material.albedoTexture;
//material.albedoColor = new Color3(1, 1, 1);
//material.emissiveColor = new Color3(1, 1, 1);
this._rockMesh.material = this._rockMaterial;
importMesh.meshes[1].dispose(false, true);
importMesh.meshes[0].dispose();
}
} }
} }
private static async loadMesh() {
console.log('loading mesh');
const importMesh = await SceneLoader.ImportMeshAsync(null, "./", "asteroid2.glb", DefaultScene.MainScene);
this._rockMesh = importMesh.meshes[1].clone("asteroid", null, false);
this._rockMesh.setParent(null);
this._rockMesh.setEnabled(false);
//importMesh.meshes[1].dispose();
console.log(importMesh.meshes);
if (!this._rockMaterial) {
this._rockMaterial = this._rockMesh.material.clone("asteroid") as PBRMaterial;
this._rockMaterial.name = 'asteroid-material';
this._rockMaterial.id = 'asteroid-material';
const material = (this._rockMaterial as PBRMaterial)
const noiseTexture = new NoiseProceduralTexture("asteroid-noise", 256, DefaultScene.MainScene);
noiseTexture.brightness = 0.6; // Brighter base color
noiseTexture.octaves = 4; // More detaila
material.albedoTexture = noiseTexture;
material.roughness = 1;
this._rockMesh.material = material;
importMesh.meshes[1].dispose(false, true);
importMesh.meshes[0].dispose();
}
}
private static getExplosionFromPool(): ParticleSystemSet | null { private static getExplosionFromPool(): ParticleSystemSet | null {
return this._explosionPool.pop() || null; return this._explosionPool.pop() || null;
} }
@ -102,19 +103,22 @@ export class RockFactory {
rock.id = "asteroid-" + i; rock.id = "asteroid-" + i;
rock.metadata = {type: 'asteroid'}; rock.metadata = {type: 'asteroid'};
rock.setEnabled(true); rock.setEnabled(true);
console.log(rock.getBoundingInfo());
const agg = new PhysicsAggregate(rock, PhysicsShapeType.CONVEX_HULL, { // PhysicsAggregate will automatically compute sphere size from mesh bounding info
// The mesh scaling is already applied, so Babylon will create correctly sized physics shape
const agg = new PhysicsAggregate(rock, PhysicsShapeType.SPHERE, {
mass: 10000, mass: 10000,
restitution: .5, restitution: .5
// Don't pass radius - let Babylon compute from scaled mesh bounds
}, DefaultScene.MainScene); }, DefaultScene.MainScene);
const body =agg.body; const body = agg.body;
if (!this._viewer) { if (!this._viewer) {
this._viewer = new PhysicsViewer(DefaultScene.MainScene); // this._viewer = new PhysicsViewer(DefaultScene.MainScene);
} }
//this._viewer.showBody(body); // this._viewer.showBody(body);
body.setLinearDamping(0); body.setLinearDamping(0)
body.setMotionType(PhysicsMotionType.DYNAMIC); body.setMotionType(PhysicsMotionType.DYNAMIC);
body.setCollisionCallbackEnabled(true); body.setCollisionCallbackEnabled(true);
let scaling = Vector3.One(); let scaling = Vector3.One();