feat: brand new default skybox with fog, better daycycle and colors (#425)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Vitaly 2025-09-05 05:02:54 +03:00 committed by GitHub
commit b2e36840b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 372 additions and 74 deletions

View file

@ -233,3 +233,4 @@ Only during development:
- [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true)
- [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser)
- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here

View file

@ -7,6 +7,7 @@ import { Vec3 } from 'vec3'
import { BotEvents } from 'mineflayer'
import { proxy } from 'valtio'
import TypedEmitter from 'typed-emitter'
import { Biome } from 'minecraft-data'
import { delayedIterator } from '../../playground/shared'
import { chunkPos } from './simpleUtils'
@ -28,6 +29,8 @@ export type WorldDataEmitterEvents = {
updateLight: (data: { pos: Vec3 }) => void
onWorldSwitch: () => void
end: () => void
biomeUpdate: (data: { biome: Biome }) => void
biomeReset: () => void
}
export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
@ -360,8 +363,37 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
}
lastBiomeId: number | null = null
udpateBiome (pos: Vec3) {
try {
const biomeId = this.world.getBiome(pos)
if (biomeId !== this.lastBiomeId) {
this.lastBiomeId = biomeId
const biomeData = loadedData.biomes[biomeId]
if (biomeData) {
this.emitter.emit('biomeUpdate', {
biome: biomeData
})
} else {
// unknown biome
this.emitter.emit('biomeReset')
}
}
} catch (e) {
console.error('error updating biome', e)
}
}
lastPosCheck: Vec3 | null = null
async updatePosition (pos: Vec3, force = false) {
if (!this.allowPositionUpdate) return
const posFloored = pos.floored()
if (!force && this.lastPosCheck && this.lastPosCheck.equals(posFloored)) return
this.lastPosCheck = posFloored
this.udpateBiome(pos)
const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ || force) {

View file

@ -32,31 +32,44 @@ const toMajorVersion = version => {
export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
// Debug settings
showChunkBorders: false,
enableDebugOverlay: false,
// Performance settings
mesherWorkers: 4,
isPlayground: false,
renderEars: true,
skinTexturesProxy: undefined as string | undefined,
// game renderer setting actually
showHand: false,
viewBobbing: false,
extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
addChunksBatchWaitTime: 200,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false,
// Rendering engine settings
dayCycle: true,
smoothLighting: true,
enableLighting: true,
starfield: true,
addChunksBatchWaitTime: 200,
renderEntities: true,
extraBlockRenderers: true,
foreground: true,
fov: 75,
volume: 1,
// Camera visual related settings
showHand: false,
viewBobbing: false,
renderEars: true,
highlightBlockColor: 'blue',
// Player models
fetchPlayerSkins: true,
skinTexturesProxy: undefined as string | undefined,
// VR settings
vrSupport: true,
vrPageGameRendering: true,
renderEntities: true,
fov: 75,
fetchPlayerSkins: true,
highlightBlockColor: 'blue',
foreground: true,
enableDebugOverlay: false,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false,
volume: 1
// World settings
clipWorldBelowY: undefined as number | undefined,
isPlayground: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
@ -496,6 +509,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
timeUpdated? (newTime: number): void
biomeUpdated? (biome: any): void
biomeReset? (): void
updateViewerPosition (pos: Vec3) {
this.viewerChunkPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) {
@ -817,12 +834,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
})
worldEmitter.on('time', (timeOfDay) => {
if (!this.worldRendererConfig.dayCycle) return
this.timeUpdated?.(timeOfDay)
if (timeOfDay < 0 || timeOfDay > 24_000) {
throw new Error('Invalid time of day. It should be between 0 and 24000.')
}
this.timeOfTheDay = timeOfDay
// if (this.worldRendererConfig.skyLight === skyLight) return
@ -831,6 +845,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// (this).rerenderAllChunks?.()
// }
})
worldEmitter.on('biomeUpdate', ({ biome }) => {
this.biomeUpdated?.(biome)
})
worldEmitter.on('biomeReset', () => {
this.biomeReset?.()
})
}
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {

View file

@ -1,10 +1,28 @@
import * as THREE from 'three'
export const DEFAULT_TEMPERATURE = 0.75
export class SkyboxRenderer {
private texture: THREE.Texture | null = null
private mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial> | null = null
private skyMesh: THREE.Mesh | null = null
private voidMesh: THREE.Mesh | null = null
constructor (private readonly scene: THREE.Scene, public initialImage: string | null) {}
// World state
private worldTime = 0
private partialTicks = 0
private viewDistance = 4
private temperature = DEFAULT_TEMPERATURE
private inWater = false
private waterBreathing = false
private fogBrightness = 0
private prevFogBrightness = 0
constructor (private readonly scene: THREE.Scene, public initialImage: string | null) {
if (!initialImage) {
this.createGradientSky()
}
}
async init () {
if (this.initialImage) {
@ -58,10 +76,255 @@ export class SkyboxRenderer {
}
}
update (cameraPosition: THREE.Vector3) {
if (this.mesh) {
this.mesh.position.copy(cameraPosition)
update (cameraPosition: THREE.Vector3, newViewDistance: number) {
if (newViewDistance !== this.viewDistance) {
this.viewDistance = newViewDistance
this.updateSkyColors()
}
if (this.mesh) {
// Update skybox position
this.mesh.position.copy(cameraPosition)
} else if (this.skyMesh) {
// Update gradient sky position
this.skyMesh.position.copy(cameraPosition)
this.voidMesh?.position.copy(cameraPosition)
this.updateSkyColors() // Update colors based on time of day
}
}
// Update world time
updateTime (timeOfDay: number, partialTicks = 0) {
this.worldTime = timeOfDay
this.partialTicks = partialTicks
this.updateSkyColors()
}
// Update view distance
updateViewDistance (viewDistance: number) {
this.viewDistance = viewDistance
this.updateSkyColors()
}
// Update temperature (for biome support)
updateTemperature (temperature: number) {
this.temperature = temperature
this.updateSkyColors()
}
// Update water state
updateWaterState (inWater: boolean, waterBreathing: boolean) {
this.inWater = inWater
this.waterBreathing = waterBreathing
this.updateSkyColors()
}
private createGradientSky () {
const size = 64
const scale = 256 / size + 2
{
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, 16, 0)
const material = new THREE.MeshBasicMaterial({
color: 0xff_ff_ff,
side: THREE.DoubleSide,
depthTest: false
})
this.skyMesh = new THREE.Mesh(geometry, material)
this.scene.add(this.skyMesh)
}
{
const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2)
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, -16, 0)
const material = new THREE.MeshBasicMaterial({
color: 0xff_ff_ff,
side: THREE.DoubleSide,
depthTest: false
})
this.voidMesh = new THREE.Mesh(geometry, material)
this.scene.add(this.voidMesh)
}
this.updateSkyColors()
}
private getFogColor (partialTicks = 0): THREE.Vector3 {
const angle = this.getCelestialAngle(partialTicks)
let rotation = Math.cos(angle * Math.PI * 2) * 2 + 0.5
rotation = Math.max(0, Math.min(1, rotation))
let x = 0.752_941_2
let y = 0.847_058_83
let z = 1
x *= (rotation * 0.94 + 0.06)
y *= (rotation * 0.94 + 0.06)
z *= (rotation * 0.91 + 0.09)
return new THREE.Vector3(x, y, z)
}
private getSkyColor (x = 0, z = 0, partialTicks = 0): THREE.Vector3 {
const angle = this.getCelestialAngle(partialTicks)
let brightness = Math.cos(angle * 3.141_593 * 2) * 2 + 0.5
if (brightness < 0) brightness = 0
if (brightness > 1) brightness = 1
const temperature = this.getTemperature(x, z)
const rgb = this.getSkyColorByTemp(temperature)
const red = ((rgb >> 16) & 0xff) / 255
const green = ((rgb >> 8) & 0xff) / 255
const blue = (rgb & 0xff) / 255
return new THREE.Vector3(
red * brightness,
green * brightness,
blue * brightness
)
}
private calculateCelestialAngle (time: number, partialTicks: number): number {
const modTime = (time % 24_000)
let angle = (modTime + partialTicks) / 24_000 - 0.25
if (angle < 0) {
angle++
}
if (angle > 1) {
angle--
}
angle = 1 - ((Math.cos(angle * Math.PI) + 1) / 2)
angle += (angle - angle) / 3
return angle
}
private getCelestialAngle (partialTicks: number): number {
return this.calculateCelestialAngle(this.worldTime, partialTicks)
}
private getTemperature (x: number, z: number): number {
return this.temperature
}
private getSkyColorByTemp (temperature: number): number {
temperature /= 3
if (temperature < -1) temperature = -1
if (temperature > 1) temperature = 1
const hue = 0.622_222_2 - temperature * 0.05
const saturation = 0.5 + temperature * 0.1
const brightness = 1
return this.hsbToRgb(hue, saturation, brightness)
}
private hsbToRgb (hue: number, saturation: number, brightness: number): number {
let r = 0; let g = 0; let b = 0
if (saturation === 0) {
r = g = b = Math.floor(brightness * 255 + 0.5)
} else {
const h = (hue - Math.floor(hue)) * 6
const f = h - Math.floor(h)
const p = brightness * (1 - saturation)
const q = brightness * (1 - saturation * f)
const t = brightness * (1 - (saturation * (1 - f)))
switch (Math.floor(h)) {
case 0:
r = Math.floor(brightness * 255 + 0.5)
g = Math.floor(t * 255 + 0.5)
b = Math.floor(p * 255 + 0.5)
break
case 1:
r = Math.floor(q * 255 + 0.5)
g = Math.floor(brightness * 255 + 0.5)
b = Math.floor(p * 255 + 0.5)
break
case 2:
r = Math.floor(p * 255 + 0.5)
g = Math.floor(brightness * 255 + 0.5)
b = Math.floor(t * 255 + 0.5)
break
case 3:
r = Math.floor(p * 255 + 0.5)
g = Math.floor(q * 255 + 0.5)
b = Math.floor(brightness * 255 + 0.5)
break
case 4:
r = Math.floor(t * 255 + 0.5)
g = Math.floor(p * 255 + 0.5)
b = Math.floor(brightness * 255 + 0.5)
break
case 5:
r = Math.floor(brightness * 255 + 0.5)
g = Math.floor(p * 255 + 0.5)
b = Math.floor(q * 255 + 0.5)
break
}
}
return 0xff_00_00_00 | (r << 16) | (g << 8) | (Math.trunc(b))
}
private updateSkyColors () {
if (!this.skyMesh || !this.voidMesh) return
// Update fog brightness with smooth transition
this.prevFogBrightness = this.fogBrightness
const renderDistance = this.viewDistance / 32
const brightnessAtPosition = 1 // Could be affected by light level in future
const targetBrightness = brightnessAtPosition * (1 - renderDistance) + renderDistance
this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1
// Handle water fog
if (this.inWater) {
const waterViewDistance = this.waterBreathing ? 100 : 5
this.scene.fog = new THREE.Fog(new THREE.Color(0, 0, 1), 0.0025, waterViewDistance)
this.scene.background = new THREE.Color(0, 0, 1)
// Update sky and void colors for underwater effect
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 1))
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 0.6))
return
}
// Normal sky colors
const viewDistance = this.viewDistance * 16
const viewFactor = 1 - (0.25 + 0.75 * this.viewDistance / 32) ** 0.25
const angle = this.getCelestialAngle(this.partialTicks)
const skyColor = this.getSkyColor(0, 0, this.partialTicks)
const fogColor = this.getFogColor(this.partialTicks)
const brightness = Math.cos(angle * Math.PI * 2) * 2 + 0.5
const clampedBrightness = Math.max(0, Math.min(1, brightness))
// Interpolate fog brightness
const interpolatedBrightness = this.prevFogBrightness + (this.fogBrightness - this.prevFogBrightness) * this.partialTicks
const red = (fogColor.x + (skyColor.x - fogColor.x) * viewFactor) * clampedBrightness * interpolatedBrightness
const green = (fogColor.y + (skyColor.y - fogColor.y) * viewFactor) * clampedBrightness * interpolatedBrightness
const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness
this.scene.background = new THREE.Color(red, green, blue)
this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * 2)
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z))
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(
skyColor.x * 0.2 + 0.04,
skyColor.y * 0.2 + 0.04,
skyColor.z * 0.6 + 0.1
))
}
dispose () {
@ -73,5 +336,15 @@ export class SkyboxRenderer {
;(this.mesh.material as THREE.Material).dispose()
this.scene.remove(this.mesh)
}
if (this.skyMesh) {
this.skyMesh.geometry.dispose()
;(this.skyMesh.material as THREE.Material).dispose()
this.scene.remove(this.skyMesh)
}
if (this.voidMesh) {
this.voidMesh.geometry.dispose()
;(this.voidMesh.material as THREE.Material).dispose()
this.scene.remove(this.voidMesh)
}
}
}

View file

@ -3,6 +3,7 @@ import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt'
import PrismarineChatLoader from 'prismarine-chat'
import * as tweenJs from '@tweenjs/tween.js'
import { Biome } from 'minecraft-data'
import { renderSign } from '../sign-renderer'
import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer'
import { chunkPos, sectionPos } from '../lib/simpleUtils'
@ -24,7 +25,7 @@ import { CameraShake } from './cameraShake'
import { ThreeJsMedia } from './threeJsMedia'
import { Fountain } from './threeJsParticles'
import { WaypointsRenderer } from './waypoints'
import { SkyboxRenderer } from './skyboxRenderer'
import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer'
type SectionKey = string
@ -173,7 +174,10 @@ export class WorldRendererThree extends WorldRendererCommon {
override watchReactivePlayerState () {
super.watchReactivePlayerState()
this.onReactivePlayerStateUpdated('inWater', (value) => {
this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null
this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing)
})
this.onReactivePlayerStateUpdated('waterBreathing', (value) => {
this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value)
})
this.onReactivePlayerStateUpdated('ambientLight', (value) => {
if (!value) return
@ -264,6 +268,19 @@ export class WorldRendererThree extends WorldRendererCommon {
} else {
this.starField.remove()
}
this.skyboxRenderer.updateTime(newTime)
}
biomeUpdated (biome: Biome): void {
if (biome?.temperature !== undefined) {
this.skyboxRenderer.updateTemperature(biome.temperature)
}
}
biomeReset (): void {
// Reset to default temperature when biome is unknown
this.skyboxRenderer.updateTemperature(DEFAULT_TEMPERATURE)
}
getItemRenderData (item: Record<string, any>, specificProps: ItemSpecificContextProperties) {
@ -716,7 +733,7 @@ export class WorldRendererThree extends WorldRendererCommon {
// Update skybox position to follow camera
const cameraPos = this.getCameraPosition()
this.skyboxRenderer.update(cameraPos)
this.skyboxRenderer.update(cameraPos, this.viewDistance)
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
if (sizeOrFovChanged) {

View file

@ -1,46 +0,0 @@
import { options } from './optionsStorage'
import { assertDefined } from './utils'
import { updateBackground } from './water'
export default () => {
const timeUpdated = () => {
// 0 morning
const dayTotal = 24_000
const evening = 11_500
const night = 13_500
const morningStart = 23_000
const morningEnd = 23_961
const timeProgress = options.dayCycleAndLighting ? bot.time.timeOfDay : 0
// todo check actual colors
const dayColorRainy = { r: 111 / 255, g: 156 / 255, b: 236 / 255 }
// todo yes, we should make animations (and rain)
// eslint-disable-next-line unicorn/numeric-separators-style
const dayColor = bot.isRaining ? dayColorRainy : { r: 0.6784313725490196, g: 0.8470588235294118, b: 0.9019607843137255 } // lightblue
// let newColor = dayColor
let int = 1
if (timeProgress < evening) {
// stay dayily
} else if (timeProgress < night) {
const progressNorm = timeProgress - evening
const progressMax = night - evening
int = 1 - progressNorm / progressMax
} else if (timeProgress < morningStart) {
int = 0
} else if (timeProgress < morningEnd) {
const progressNorm = timeProgress - morningStart
const progressMax = night - morningEnd
int = progressNorm / progressMax
}
// todo need to think wisely how to set these values & also move directional light around!
const colorInt = Math.max(int, 0.1)
updateBackground({ r: dayColor.r * colorInt, g: dayColor.g * colorInt, b: dayColor.b * colorInt })
if (!options.newVersionsLighting && bot.supportFeature('blockStateId')) {
appViewer.playerState.reactive.ambientLight = Math.max(int, 0.25)
appViewer.playerState.reactive.directionalLight = Math.min(int, 0.5)
}
}
bot.on('time', timeUpdated)
timeUpdated()
}

View file

@ -56,7 +56,6 @@ import { isCypress } from './standaloneUtils'
import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer'
import defaultServerOptions from './defaultLocalServerOptions'
import dayCycle from './dayCycle'
import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack'
import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer'
@ -794,7 +793,6 @@ export async function connect (connectOptions: ConnectOptions) {
}
initMotionTracking()
dayCycle()
// Bot position callback
const botPosition = () => {

View file

@ -128,5 +128,6 @@ export const watchOptionsAfterWorldViewInit = (worldView: WorldDataEmitter) => {
appViewer.inWorldRenderingConfig.renderEars = o.renderEars
appViewer.inWorldRenderingConfig.showHand = o.showHand
appViewer.inWorldRenderingConfig.viewBobbing = o.viewBobbing
appViewer.inWorldRenderingConfig.dayCycle = o.dayCycleAndLighting
})
}