import { EditMeshController } from "./edit-mesh-controller.js"; import { captureEditMesh } from "./edit-mesh.js"; import { multiply, perspective, viewMatrix, key, unitBox, project, } from "./math.js"; import { getBlockFaceTextures } from "./block-textures.js"; import { prepareTexturePack } from "./texture-pack.js"; import { boxVertices, buildSectionMesh } from "./mesh-geometry.js"; import { TerrainController } from "./terrain-controller.js"; import { affectedSectionKeys } from "./ambient-occlusion.js"; import { shadowFrame } from "./shadow-frame.js"; import { BlockLightController, inferLightBounds } from "./block-light-controller.js"; import { changedLightSections } from "./light-changes.js"; import { viewDistanceProfile } from "./view-distance.js"; import { worldVertex as vertex, worldFragment as fragment, skyVertex as skyVert, skyFragment as skyFrag, shadowVertex, shadowFragment, SUN_DIRECTION, } from "./lighting-shaders.js"; const lineVertex = `#version 300 es precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;uniform vec3 uOffset;void main(){gl_Position=uVP*vec4(aPos+uOffset,1.);}`; const lineFragment = `#version 300 es precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`; function program(gl, vs, fs) { const p = gl.createProgram(); for (const [type, source] of [ [gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs], ]) { const s = gl.createShader(type); gl.shaderSource(s, source); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error(gl.getShaderInfoLog(s)); gl.attachShader(p, s); gl.deleteShader(s); } gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw Error(gl.getProgramInfoLog(p)); return p; } /** Avatar geometry follows the public physics pose; yaw remains interpolated. */ export function playerAvatarParts(player) { const pose = player.pose || "standing", swimming = pose === "swimming", crouching = pose === "crouching", defaultHeight = swimming ? 0.6 : crouching ? 1.5 : 1.8, height = Number.isFinite(player.height) ? Math.max(0.4, Math.min(2.5, player.height)) : defaultHeight, eye = Number.isFinite(player.eye_height) ? player.eye_height : height - (swimming ? 0.2 : crouching ? 0.23 : 0.18), shirt = player.color || [0.29, 0.46, 0.36], skin = [0.77, 0.61, 0.43], trousers = [0.25, 0.3, 0.32], parts = [], add = (part, min, max, color, material = 0) => parts.push({ part, box: { min, max }, color, material }); if (swimming) { // The collider is short while the visible body lies along its facing axis. add( "torso", [-0.21, height * 0.24, -0.36], [0.21, height * 0.7, 0.28], shirt, 2, ); add("head", [-0.23, height * 0.2, -0.82], [0.23, height, -0.36], skin); for (const side of [-1, 1]) { add( "leg", [side * 0.13 - 0.085, height * 0.27, 0.28], [side * 0.13 + 0.085, height * 0.57, 0.89], trousers, ); add( "arm", [side * 0.31 - 0.085, height * 0.22, -0.38], [side * 0.31 + 0.085, height * 0.55, 0.24], shirt, ); } add( "eyes", [-0.18, eye - 0.04, -0.824], [0.18, eye + 0.04, -0.816], [0.13, 0.19, 0.15], ); } else { const headBottom = height - 0.5, hip = crouching ? headBottom - 0.55 : headBottom - 0.64, lean = crouching ? -0.13 : 0; add( "torso", [-0.21, hip, -0.13 + lean * 0.5], [0.21, headBottom, 0.13 + lean * 0.5], shirt, 2, ); add( "head", [-0.23, headBottom, -0.23 + lean], [0.23, height, 0.23 + lean], skin, ); for (const side of [-1, 1]) { const knee = crouching ? 0.07 : 0; add( "leg", [side * 0.13 - 0.085, 0.02, -0.09 + knee], [side * 0.13 + 0.085, hip, 0.09 + knee], trousers, ); add( "arm", [side * 0.31 - 0.085, hip, -0.1 + lean], [side * 0.31 + 0.085, headBottom - 0.03, 0.1 + lean], shirt, ); } add( "eyes", [-0.18, eye - 0.04, -0.234 + lean], [0.18, eye + 0.04, -0.226 + lean], [0.13, 0.19, 0.15], ); } return parts; } const section = (p) => p.map((v) => Math.floor(v / 16)).join(","); export class Renderer { constructor(canvas) { this.canvas = canvas; this.renderOrigin = [0,0,0]; this.selectionOrigin = [0,0,0]; const gl = (this.gl = canvas.getContext("webgl2", { antialias: true, alpha: false, preserveDrawingBuffer: true, powerPreference: "high-performance", })); if (!gl) throw Error( "WebGL2 недоступен. Включите аппаратное ускорение или откройте клиент в браузере с поддержкой WebGL2.", ); this.program = program(gl, vertex, fragment); this.sky = program(gl, skyVert, skyFrag); this.line = program(gl, lineVertex, lineFragment); this.shadowProgram = program(gl, shadowVertex, shadowFragment); this.shadowSize = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE)); this.shadowRadius = 48; this.shadowsEnabled = true; this.shadowReady = this.createShadowMap(); this.lightingName = "Daylight"; this.daylight = 1; this.sections = new Map(); this.publishedSections = new Set(); this.loadStarted = performance.now(); this.firstTerrainMs = null; this.dirty = new Set(); this.blocks = new Map(); this.materials = new Map(); this.blockLightField = null; this.lightViewBounds = null; this.meshUpload = null; this.terrain = new TerrainController({ onLight: field => { this.blockLightField = field; }, onDirty: ids => { for (const id of ids) if (this.sectionInView(id)) this.dirty.add(id); }, onError: () => this.enableTerrainFallback(), }); if (this.terrain.error) { // Some embedded browsers do not support workers. Keep the game usable // through the former CPU path, and identify it in diagnostics. this.terrain.dispose(); this.terrain = null; this.blockLighting = new BlockLightController(field => { for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id); this.blockLightField = field; }); } else this.blockLighting = this.terrain; if (this.terrain) this.edits = new EditMeshController({ capture: id => captureEditMesh(id, this.blocks, this.materials, this.textureLayers, this.blockLightField), onError: error => console.warn("Shacraft edit worker unavailable; using terrain queue", error), }); this.texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([180, 180, 180, 255]), ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); this.textured = false; this.effectTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.effectTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([180, 180, 180, 255]), ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); this.effectTextured = false; this.blockTexture = this.createBlockTexture(1, 1); this.blockAtlasGrid = [0, 0]; this.blockGrassOverlay = [-1, -1]; this.textureLayers = new Map(); this.faceTextureCache = new Map(); this.texturePackGeneration = 0; this.bounceAt = -10000; this.dynamic = this.mesh([]); this.lines = this.mesh([], 3); this.triangles = 0; this.meshRebuilds = 0; this.meshResets = 0; this.vp = new Float32Array(16); this.lost = false; canvas.addEventListener("webglcontextlost", (e) => { e.preventDefault(); this.lost = true; }); canvas.addEventListener("webglcontextrestored", () => location.reload()); } createShadowMap() { const gl = this.gl; this.shadowTexture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, this.shadowSize, this.shadowSize, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); this.shadowFramebuffer = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTexture, 0); gl.drawBuffers([gl.NONE]); gl.readBuffer(gl.NONE); const ready = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; gl.bindFramebuffer(gl.FRAMEBUFFER, null); return ready; } drawShadows(frame) { if (!this.shadowsEnabled || !this.shadowReady) return; const gl = this.gl, p = this.shadowProgram; gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFramebuffer); gl.viewport(0, 0, this.shadowSize, this.shadowSize); gl.colorMask(false, false, false, false); gl.depthMask(true); gl.clear(gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.frontFace(gl.CCW); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1.1, 2.); gl.useProgram(p); gl.uniformMatrix4fv(gl.getUniformLocation(p, "uLightVP"), false, frame.matrix); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture); gl.uniform1i(gl.getUniformLocation(p, "uBlockTextures"), 2); gl.uniform2fv(gl.getUniformLocation(p, "uBlockAtlasGrid"), this.blockAtlasGrid); gl.uniform2fv(gl.getUniformLocation(p, "uGrassOverlay"), this.blockGrassOverlay); for (const mesh of this.sections.values()) { this.meshOffset(p,mesh.origin); gl.bindVertexArray(mesh.vao); gl.drawArrays(gl.TRIANGLES, 0, mesh.count); } this.meshOffset(p,this.renderOrigin); gl.bindVertexArray(this.dynamic.vao); gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count); gl.disable(gl.POLYGON_OFFSET_FILL); gl.colorMask(true, true, true, true); gl.bindFramebuffer(gl.FRAMEBUFFER, null); } meshOffset(program,origin=[0,0,0]) { this.gl.uniform3fv(this.gl.getUniformLocation(program,"uOffset"),origin.map((v,i)=>v-this.renderOrigin[i])); } mesh(data, stride = 20) { const gl = this.gl, vao = gl.createVertexArray(), buffer = gl.createBuffer(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW); for (const [loc, size, offset] of stride === 3 ? [[0, 3, 0]] : [ [0, 3, 0], [1, 3, 3], [2, 3, 6], [3, 1, 9], [4, 1, 10], [5, 3, 11], [6, 2, 14], [7, 4, 16], ]) { gl.enableVertexAttribArray(loc); gl.vertexAttribPointer( loc, size, gl.FLOAT, false, stride * 4, offset * 4, ); } return { vao, buffer, count: data.length / stride }; } disposeMesh(mesh) { if (mesh.transparent) this.disposeMesh(mesh.transparent); this.gl.deleteBuffer(mesh.buffer); this.gl.deleteVertexArray(mesh.vao); } upload(mesh, data, stride = 20) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW); mesh.count = data.length / stride; } setBounceShader(source) { if (source.length > 32768) throw Error("Шейдер пакета слишком велик"); const next = program( this.gl, vertex, fragment.replace( "vec3 shacraftBounceTint(vec3 color,float pulse){return color;}", source, ), ); this.gl.deleteProgram(this.program); this.program = next; } async setTexture(blob, effect = false) { const bitmap = await createImageBitmap(blob); this.gl.bindTexture( this.gl.TEXTURE_2D, effect ? this.effectTexture : this.texture, ); this.gl.texImage2D( this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, bitmap, ); this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT, ); this.gl.texParameteri( this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT, ); bitmap.close(); if (effect) this.effectTextured = true; else this.textured = true; } createBlockTexture(size, layers, height = size) { const gl = this.gl, texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture); gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, size, height, layers); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT); return texture; } clearTexturePack() { this.texturePackGeneration++; this.blockAtlasGrid = [0, 0]; this.blockGrassOverlay = [-1, -1]; this.textureLayers.clear(); this.faceTextureCache.clear(); this.gl.deleteTexture(this.blockTexture); this.blockTexture = this.createBlockTexture(1, 1); this.invalidate(); } async setTexturePack(descriptor, files) { const gl = this.gl; const {layers, width, height, atlas, entries, grassOverlay} = prepareTexturePack(descriptor, files, { layers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS), size: gl.getParameter(gl.MAX_TEXTURE_SIZE), }); const bitmaps = []; const generation = ++this.texturePackGeneration; let next; try { for (const entry of atlas ? [atlas] : entries) { const bitmap = await createImageBitmap(files.get(entry.path), { premultiplyAlpha: "none", colorSpaceConversion: "none", }); bitmaps.push(bitmap); if (generation !== this.texturePackGeneration) return; if (bitmap.width !== width || bitmap.height !== height) throw Error(`Текстура ${entry.name || "atlas"} должна быть ${width}×${height}.`); } // Separate array layers prevent atlas bleeding; only native pixel data reaches the GPU. next = this.createBlockTexture(width, bitmaps.length, height); for (let i = 0; i < bitmaps.length; i++) gl.texSubImage3D( gl.TEXTURE_2D_ARRAY, 0, 0, 0, i, width, height, 1, gl.RGBA, gl.UNSIGNED_BYTE, bitmaps[i], ); gl.deleteTexture(this.blockTexture); this.blockTexture = next; next = null; this.textureLayers = layers; this.blockAtlasGrid = atlas ? [atlas.columns, atlas.rows] : [0, 0]; this.blockGrassOverlay = grassOverlay; this.faceTextureCache.clear(); this.invalidate(); } finally { for (const bitmap of bitmaps) bitmap.close(); if (next) gl.deleteTexture(next); } } faceTextures(mat) { if (!this.textureLayers.size) return null; if (!this.faceTextureCache.has(mat.state)) this.faceTextureCache.set( mat.state, getBlockFaceTextures(mat.state, this.textureLayers), ); return this.faceTextureCache.get(mat.state); } replace(blocks, materials) { this.meshResets++; this.loadStarted = performance.now(); this.firstTerrainMs = null; this.publishedSections?.clear(); this.edits?.reset(); for (const m of this.sections.values()) this.disposeMesh(m); this.sections.clear(); this.dirty.clear(); this.blocks = blocks; this.materials = materials; this.blockLightField = null; if (this.terrain) this.terrain.reset(blocks, materials, this.textureLayers, this.lightViewBounds); else this.requestBlockLighting(); if (blocks.loadedSectionKeys) for (const id of blocks.loadedSectionKeys()) this.dirty.add(id); else for (const k of blocks.keys()) this.dirty.add(section(k.split(",").map(Number))); } change(changes, sectionIds = null, sectionRecords = null) { if (!sectionIds && changes.length > 64) this.edits?.reset(); const owners = changes.map(({pos}) => section(pos)); const urgent = !sectionIds && changes.length <= 64 ? [...new Set([...owners, ...changes.flatMap(({pos}) => affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }]).filter(id => this.sections.has(id) || this.edits?.tickets.has(id)))])].filter(id => this.sectionInView(id)) : []; if (changes.length || sectionRecords) { if (this.terrain) this.terrain.update({ ...(sectionRecords ? { sections: sectionRecords } : { changes }), materials: this.materials }); else this.requestBlockLighting(); } if (sectionIds) { const affected = new Set(); // A stream packet already identifies whole entering/departing sections. // Mark their halos once instead of repeating the same work per voxel. for (const coords of sectionIds) for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) { const id = coords.map((v, axis) => v + [x, y, z][axis]).join(","); if (this.sectionInView(id)) this.dirty.add(id); affected.add(id); } this.edits?.request([...this.edits.tickets.keys()].filter(id => affected.has(id))); return; } this.edits?.request(urgent); for (const { pos } of changes) { // Geometry and corner lighting can cross section edges diagonally. for (const id of affectedSectionKeys(pos, [{ min: [-2, -2, -2], max: [3, 3, 3] }])) this.dirty.add(id); } } unloadSections(ids) { if (ids.length) { if (this.terrain) this.terrain.update({ unload: ids }); else this.requestBlockLighting(); } const removed = new Set(ids.map((id) => id.join(","))); for (const id of removed) { this.edits?.cancel(id); this.publishedSections?.delete(id); const mesh = this.sections.get(id); if (mesh) this.disposeMesh(mesh); this.sections.delete(id); this.dirty.delete(id); } // Retained face and diagonal neighbors may lose an occluder. const refreshEdits = new Set(); for (const coords of ids) for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) { const neighbor = coords.map((v, axis) => v + [x, y, z][axis]).join(","); if (!removed.has(neighbor) && this.sections.has(neighbor)) this.dirty.add(neighbor); if (!removed.has(neighbor) && this.edits?.tickets.has(neighbor)) refreshEdits.add(neighbor); } this.edits?.request([...refreshEdits]); } invalidate() { this.edits?.reset(); if (this.terrain) this.terrain.update({ materials: this.materials, textures: this.textureLayers }); else this.requestBlockLighting(); if (this.blocks.loadedSectionKeys) for (const id of this.blocks.loadedSectionKeys()) this.dirty.add(id); else for (const k of this.blocks.keys()) this.dirty.add(section(k.split(",").map(Number))); } setLightBounds(bounds) { if (JSON.stringify(bounds) === JSON.stringify(this.lightViewBounds)) return; this.lightViewBounds = bounds; if (this.terrain) this.terrain.update({ bounds }); else this.requestBlockLighting(); } requestBlockLighting() { if (this.terrain) { this.terrain.update({ materials: this.materials }); return; } if (this.blockLighting) this.blockLighting.request(this.blocks, this.materials, this.lightViewBounds || inferLightBounds(this.blocks)); } enableTerrainFallback() { if (!this.terrain) return; this.terrain.dispose(); this.edits?.dispose(); this.discardMeshUpload(); this.terrain = null; this.blockLighting = new BlockLightController(field => { for (const id of changedLightSections(this.blockLightField, field, this.sections.keys())) this.dirty.add(id); this.blockLightField = field; }); this.requestBlockLighting(); for (const key of this.blocks.keys()) this.dirty.add(section(key.split(",").map(Number))); } sectionInView(id) { if (!this.lightViewBounds) return true; return id.split(",").every((value, axis) => { const base = Number(value) * 16; return base <= this.lightViewBounds.max[axis] && base + 15 >= this.lightViewBounds.min[axis]; }); } discardMeshUpload() { if (!this.meshUpload) return; for (const part of this.meshUpload.parts) if (part.mesh) this.disposeMesh(part.mesh); this.meshUpload = null; } isMeshCurrent(result) { return result.editJob !== undefined ? Boolean(this.edits?.isCurrent(result)) : this.terrain.isCurrent(result); } /** Upload at most 64 KiB per step; publish both surfaces only when complete. */ applyTerrainMeshes(budgetMs) { const start = performance.now(), gl = this.gl; if (this.meshUpload && !this.isMeshCurrent(this.meshUpload.result)) this.discardMeshUpload(); while (performance.now() - start < budgetMs) { if (!this.meshUpload) { const queue = this.edits?.ready.size ? this.edits.ready : this.terrain.ready; const result = queue.values().next().value; if (!result) break; queue.delete(result.id); if (!this.isMeshCurrent(result) || !this.sectionInView(result.id)) continue; const parts = [result.vertices, result.transparent].map(data => ({ data, mesh: null, offset: 0 })); this.meshUpload = { result, parts }; } const pending = this.meshUpload; const part = pending.parts.find(p => p.offset < p.data.length); if (part) { if (!part.mesh) { part.mesh = this.mesh([]); part.mesh.count = part.data.length / 20; gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer); gl.bufferData(gl.ARRAY_BUFFER, part.data.byteLength, gl.STATIC_DRAW); if (performance.now() - start >= budgetMs) break; } const end = Math.min(part.offset + 16384, part.data.length); gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.buffer); gl.bufferSubData(gl.ARRAY_BUFFER, part.offset * 4, part.data.subarray(part.offset, end)); part.offset = end; } else { const { id } = pending.result, previous = this.sections.get(id); const [opaque, transparent] = pending.parts; if (opaque.mesh || transparent.mesh) { const next = opaque.mesh || this.mesh([]); next.transparent = transparent.mesh; next.origin = pending.result.origin || [0,0,0]; if (previous) { const retired = { vao: previous.vao, buffer: previous.buffer, transparent: previous.transparent }; Object.assign(previous, next); this.disposeMesh(retired); } else this.sections.set(id, next); } else if (previous) { this.disposeMesh(previous); this.sections.delete(id); } if (!pending.result.preview) { this.dirty.delete(id); this.edits?.cancel(id); } this.publishedSections?.add(id); if (this.firstTerrainMs === null && this.sections.has(id)) this.firstTerrainMs=performance.now()-this.loadStarted; this.onMeshPublished?.(id, pending.result); this.meshRebuilds++; this.meshUpload = null; } } this.meshUploadMilliseconds = performance.now() - start; } rebuild(id) { this.meshRebuilds++; const { vertices, transparent, origin } = buildSectionMesh({ id, blocks: this.blocks, materials: this.materials, textureLayers: this.textureLayers, faceTextureCache: this.faceTextureCache, blockLightField: this.blockLightField, localCoordinates: true }); let mesh = this.sections.get(id); if (mesh) this.upload(mesh, vertices); else if (vertices.length || transparent.length) { mesh = this.mesh(vertices); this.sections.set(id, mesh); } if (mesh) { mesh.origin = origin; if (mesh.transparent) this.upload(mesh.transparent, transparent); else if (transparent.length) mesh.transparent = this.mesh(transparent); } if (mesh && vertices.length === 0 && transparent.length === 0) { this.disposeMesh(mesh); this.sections.delete(id); } } updateSelection(hit) { this.selectionOrigin = hit ? hit.pos.map(v=>Math.floor(v/16)*16) : [0,0,0]; const a = []; if (hit) { const mat = this.materials.get(hit.block), boxes = mat?.render?.length ? mat.render : [unitBox]; for (const box of boxes) { const points = []; for (let i = 0; i < 8; i++) points.push( [0, 1, 2].map( (k) => hit.pos[k] - this.selectionOrigin[k] + ((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003), ), ); for (let i = 0; i < 8; i++) for (let axis = 0; axis < 3; axis++) if (!(i & (1 << axis))) a.push(...points[i], ...points[i | (1 << axis)]); } } this.upload(this.lines, a, 3); } draw(camera, players, entities, entityDefinitions) { if (this.lost) return; const distance = viewDistanceProfile(this.viewDistance); const gl = this.gl, ratio = Math.min(devicePixelRatio || 1, 1.75), w = Math.floor(this.canvas.clientWidth * ratio), h = Math.floor(this.canvas.clientHeight * ratio); if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; } if (this.terrain) { this.edits?.pump(); this.applyTerrainMeshes(2); const eye = camera.eye; let nearest = null, distance = Infinity; if (!this.terrain.busy && !this.terrain.pending && this.terrain.ready.size < 2) for (const id of this.dirty) { if (!this.sectionInView(id)) { this.dirty.delete(id); continue; } if (this.blocks.readySections && !this.blocks.readySections.has(id)) continue; if (this.blocks.sectionIsEmpty?.(id)) { const mesh = this.sections.get(id); if (mesh) this.disposeMesh(mesh); this.sections.delete(id); this.dirty.delete(id); this.edits?.cancel(id); this.publishedSections?.add(id); continue; } if (this.terrain.ready.has(id) || this.meshUpload?.result.id === id || this.terrain.busy?.id === id) continue; const center = id.split(",").map(v => Number(v) * 16 + 8); const next = center.reduce((sum, v, axis) => sum + (v - eye[axis]) ** 2, 0); if (next < distance) { distance = next; nearest = id; } } if (nearest !== null) this.terrain.requestMesh(nearest); } else { const start = performance.now(); for (const id of this.dirty) { this.rebuild(id); this.dirty.delete(id); if (performance.now() - start > 2) break; } } this.renderOrigin = camera.eye.map(v=>Math.floor(v/16)*16); const localEye = camera.eye.map((v,i)=>v-this.renderOrigin[i]); const dynamic = []; for (const p of players) { for (const part of playerAvatarParts(p)) boxVertices( dynamic, p.position, part.box, part.color, part.material, () => false, p.yaw || 0, 1, null, "", null, 0, this.blockLightField, true, this.renderOrigin, ); } for (const e of entities) { const def = entityDefinitions.get(e.kind || e.name) || {}, color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255), boxes = e.render || def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }]; for (const b of boxes) boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0, 1, null, "", null, 0, this.blockLightField, true, this.renderOrigin); } this.upload(this.dynamic, dynamic); const shadow = shadowFrame(localEye, SUN_DIRECTION, this.shadowSize, this.shadowRadius); this.drawShadows(shadow); this.canvas.dataset.lighting = this.lightingName; const center=camera.eye.map(v=>Math.floor(v/16)); let nearReady=0,nearLoaded=0; for(let x=-1;x<=1;x++)for(let y=-1;y<=1;y++)for(let z=-1;z<=1;z++) { const id=`${center[0]+x},${center[1]+y},${center[2]+z}`; if(this.publishedSections?.has(id))nearReady++; if(this.blocks.readySections?.has(id))nearLoaded++; } this.canvas.dataset.nearMeshes=String(nearReady); this.canvas.dataset.nearLoaded=String(nearLoaded); this.canvas.dataset.firstTerrainMs=this.firstTerrainMs===null ? "" : String(Math.round(this.firstTerrainMs)); this.canvas.dataset.terrainMeshing = this.terrain?.meshWorker ? "local-light-worker" : "shared-worker"; this.canvas.dataset.localLightCells = String(this.terrain?.localLightCells || 0); this.canvas.dataset.localLightMs = String(Math.round((this.terrain?.localLightMilliseconds || 0)*100)/100); this.canvas.dataset.editPrepareMs = String(Math.round((this.edits?.prepareMilliseconds || 0) * 100) / 100); this.canvas.dataset.editMeshMs = String(Math.round((this.edits?.meshMilliseconds || 0) * 100) / 100); this.canvas.dataset.editWorker = this.edits && !this.edits.error && !this.edits.disposed ? "ready" : "fallback"; this.canvas.dataset.terrainMode = this.terrain ? "worker" : "main-thread-fallback"; this.canvas.dataset.terrainPrepareMs = String(Math.round((this.terrain?.prepareMilliseconds || 0) * 100) / 100); this.canvas.dataset.meshBuildMs = String(Math.round((this.terrain?.meshMilliseconds || 0) * 100) / 100); this.canvas.dataset.meshUploadMs = String(Math.round((this.meshUploadMilliseconds || 0) * 100) / 100); this.canvas.dataset.timeOfDay = this.daylight === 0 ? "night" : "day"; this.canvas.dataset.shadows = this.shadowsEnabled && this.shadowReady ? String(this.shadowSize) : "off"; this.canvas.dataset.blockLighting = this.blockLighting.status; this.canvas.dataset.lightSources = String(this.blockLightField?.sourceCount || 0); this.canvas.dataset.lightBuildMs = String(Math.round(this.blockLighting.milliseconds || 0)); const eyeLight = this.blockLightField?.sample(camera.eye); this.canvas.dataset.blockLight = String(eyeLight?.blockLevel ?? 0); this.canvas.dataset.skyLight = String(eyeLight?.skyLevel ?? 15); gl.viewport(0, 0, w, h); gl.clearColor(0.65, 0.79, 0.8, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE); gl.useProgram(this.sky); gl.uniform1f(gl.getUniformLocation(this.sky, "uDaylight"), this.daylight); gl.uniform1f(gl.getUniformLocation(this.sky, "uSkyExposure"), ((eyeLight?.skyLevel ?? 15) / 15) ** 2); gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch); gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw); gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h); gl.uniform1f(gl.getUniformLocation(this.sky, "uTanHalfFov"), Math.tan(Math.PI / 5.4)); gl.bindVertexArray(null); gl.drawArrays(gl.TRIANGLES, 0, 3); this.vp = multiply( perspective(Math.PI / 2.7, w / h, 0.06, distance.farClip), viewMatrix(localEye, camera.yaw, camera.pitch), ); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.frontFace(gl.CCW); gl.useProgram(this.program); gl.uniform1f(gl.getUniformLocation(this.program, "uDaylight"), this.daylight); gl.uniform1f(gl.getUniformLocation(this.program, "uFogDistance"), distance.fogDistance); gl.uniform1f(gl.getUniformLocation(this.program, "uViewDistance"), distance.radius); gl.uniformMatrix4fv( gl.getUniformLocation(this.program, "uVP"), false, this.vp, ); gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), localEye); gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "uLightVP"), false, shadow.matrix); gl.uniform3fv(gl.getUniformLocation(this.program, "uShadowCenter"), shadow.center); gl.uniform1f(gl.getUniformLocation(this.program, "uShadowRadius"), shadow.radius); gl.uniform1f(gl.getUniformLocation(this.program, "uShadowTexel"), 1 / this.shadowSize); gl.uniform1i(gl.getUniformLocation(this.program, "uShadows"), this.shadowsEnabled && this.shadowReady ? 1 : 0); gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, this.shadowTexture); gl.uniform1i(gl.getUniformLocation(this.program, "uShadowMap"), 3); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texture); gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0); gl.uniform1i( gl.getUniformLocation(this.program, "uTextured"), this.textured ? 1 : 0, ); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.effectTexture); gl.uniform1i(gl.getUniformLocation(this.program, "uEffectTexture"), 1); gl.uniform1i( gl.getUniformLocation(this.program, "uEffectTextured"), this.effectTextured ? 1 : 0, ); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.blockTexture); gl.uniform1i(gl.getUniformLocation(this.program, "uBlockTextures"), 2); gl.uniform2fv(gl.getUniformLocation(this.program, "uBlockAtlasGrid"), this.blockAtlasGrid); gl.uniform2fv(gl.getUniformLocation(this.program, "uGrassOverlay"), this.blockGrassOverlay); gl.uniform1f( gl.getUniformLocation(this.program, "uPulse"), Math.max(0, 1 - (performance.now() - this.bounceAt) / 600), ); this.triangles = 0; for (const [id, m] of this.sections) { const center = id.split(",").map((v) => Number(v) * 16 + 8); if ( Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > distance.drawRadius ) continue; this.meshOffset(this.program,m.origin); gl.bindVertexArray(m.vao); gl.drawArrays(gl.TRIANGLES, 0, m.count); this.triangles += m.count / 3; } // Actor vertices already use the camera origin. Reset the world program's // offset after terrain; its last section must never displace players/mobs. this.meshOffset(this.program,this.renderOrigin); gl.bindVertexArray(this.dynamic.vao); gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count); this.triangles += this.dynamic.count / 3; gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); const translucent = [...this.sections] .filter(([, m]) => m.transparent?.count) .sort((a, b) => { const dist = (id) => Math.hypot( ...id.split(",").map((v, i) => Number(v) * 16 + 8 - camera.eye[i]), ); return dist(b[0]) - dist(a[0]); }); for (const [, m] of translucent) { this.meshOffset(this.program,m.origin); gl.bindVertexArray(m.transparent.vao); gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count); this.triangles += m.transparent.count / 3; } gl.depthMask(true); gl.disable(gl.BLEND); if (this.lines.count) { gl.useProgram(this.line); gl.uniformMatrix4fv( gl.getUniformLocation(this.line, "uVP"), false, this.vp, ); this.meshOffset(this.line,this.selectionOrigin); gl.bindVertexArray(this.lines.vao); gl.drawArrays(gl.LINES, 0, this.lines.count); } gl.bindVertexArray(null); } project(p) { return project( p.map((v,i)=>v-this.renderOrigin[i]), this.vp, this.canvas.clientWidth, this.canvas.clientHeight, ); } }