refactor: cleanup texture atlas generators: make typed to gain confidence!

This commit is contained in:
Vitaly 2023-10-04 13:35:11 +03:00
commit d30b00c507
9 changed files with 328 additions and 204 deletions

View file

@ -8,7 +8,7 @@
"pretest": "npm run lint",
"lint": "standard",
"fix": "standard --fix",
"postinstall": "node viewer/generateTextures.js && node buildWorker.mjs"
"postinstall": "tsx viewer/prepare/generateTextures.ts && node buildWorker.mjs"
},
"author": "PrismarineJS",
"license": "MIT",

View file

@ -1,31 +0,0 @@
const path = require('path')
const { makeTextureAtlas } = require('./lib/atlas')
const { prepareBlocksStates } = require('./lib/modelsBuilder')
const mcAssets = require('minecraft-assets')
const fs = require('fs-extra')
const texturesPath = path.resolve(__dirname, '../public/textures')
if (fs.existsSync(texturesPath) && !process.argv.includes('-f')) {
console.log('textures folder already exists, skipping...')
process.exit(0)
}
fs.mkdirSync(texturesPath, { recursive: true })
const blockStatesPath = path.resolve(__dirname, '../public/blocksStates')
fs.mkdirSync(blockStatesPath, { recursive: true })
for (const version of mcAssets.versions) {
const assets = mcAssets(version)
const atlas = makeTextureAtlas(assets)
const out = fs.createWriteStream(path.resolve(texturesPath, version + '.png'))
const stream = atlas.canvas.pngStream()
stream.on('data', (chunk) => out.write(chunk))
stream.on('end', () => console.log('Generated textures/' + version + '.png'))
const blocksStates = JSON.stringify(prepareBlocksStates(assets, atlas))
fs.writeFileSync(path.resolve(blockStatesPath, version + '.json'), blocksStates)
fs.copySync(assets.directory, path.resolve(texturesPath, version), { overwrite: true })
}
fs.writeFileSync(path.resolve(__dirname, '../public/supportedVersions.json'), '[' + mcAssets.versions.map(v => `"${v}"`).toString() + ']')

View file

@ -5,26 +5,26 @@ const Entity = require('./entity/Entity')
const { dispose3 } = require('./dispose')
function getUsernameTexture(username, { fontFamily = 'sans-serif' }) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const fontSize = 50;
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`;
const fontSize = 50
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`
const textWidth = ctx.measureText(username).width + padding * 2;
const textWidth = ctx.measureText(username).width + padding * 2
canvas.width = textWidth;
canvas.height = fontSize + padding * 2;
canvas.width = textWidth
canvas.height = fontSize + padding * 2
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = 'white'
ctx.fillText(username, padding, fontSize);
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = 'white'
ctx.fillText(username, padding, fontSize)
return canvas;
return canvas
}
function getEntityMesh (entity, scene, options) {
@ -33,7 +33,7 @@ function getEntityMesh (entity, scene, options) {
const e = new Entity('1.16.4', entity.name, scene)
if (entity.username !== undefined) {
const canvas = getUsernameTexture(entity.username, options);
const canvas = getUsernameTexture(entity.username, options)
const tex = new THREE.Texture(canvas)
tex.needsUpdate = true
const spriteMat = new THREE.SpriteMaterial({ map: tex })
@ -51,7 +51,7 @@ function getEntityMesh (entity, scene, options) {
const geometry = new THREE.BoxGeometry(entity.width, entity.height, entity.width)
geometry.translate(0, entity.height / 2, 0)
const material = new THREE.MeshBasicMaterial({ color: 0xff00ff })
const material = new THREE.MeshBasicMaterial({ color: 0xff_00_ff })
const cube = new THREE.Mesh(geometry, material)
return cube
}

View file

@ -484,7 +484,7 @@ function matchProperties (block, properties) {
function getModelVariants (block) {
// air, cave_air, void_air and so on...
if (block.name.endsWith('air')) return []
if (block.name === 'air' || block.name.endsWith('_air')) return []
const state = blockStates[block.name] ?? blockStates.missing_texture
if (!state) return []
if (state.variants) {

View file

@ -1,139 +0,0 @@
function cleanupBlockName (name) {
if (name.startsWith('block') || name.startsWith('minecraft:block')) return name.split('/')[1]
return name
}
function getModel (name, blocksModels) {
name = cleanupBlockName(name)
const data = blocksModels[name]
if (!data) {
return null
}
let model = { textures: {}, elements: [], ao: true }
for (const axis in ['x', 'y', 'z']) {
if (axis in data) {
model[axis] = data[axis]
}
}
if (data.parent) {
model = getModel(data.parent, blocksModels)
}
if (data.textures) {
Object.assign(model.textures, JSON.parse(JSON.stringify(data.textures)))
}
if (data.elements) {
model.elements = JSON.parse(JSON.stringify(data.elements))
}
if (data.ambientocclusion !== undefined) {
model.ao = data.ambientocclusion
}
return model
}
function prepareModel (model, texturesJson) {
// resolve texture names eg west: #all -> blocks/stone
for (const tex in model.textures) {
let root = model.textures[tex]
while (root.charAt(0) === '#') {
root = model.textures[root.substr(1)]
}
model.textures[tex] = root
}
for (const tex in model.textures) {
let name = model.textures[tex]
name = cleanupBlockName(name)
model.textures[tex] = texturesJson[name]
}
for (const elem of model.elements) {
for (const sideName of Object.keys(elem.faces)) {
const face = elem.faces[sideName]
if (face.texture.charAt(0) === '#') {
face.texture = JSON.parse(JSON.stringify(model.textures[face.texture.substr(1)]))
} else {
let name = face.texture
name = cleanupBlockName(name)
face.texture = JSON.parse(JSON.stringify(texturesJson[name]))
}
let uv = face.uv
if (!uv) {
const _from = elem.from
const _to = elem.to
// taken from https://github.com/DragonDev1906/Minecraft-Overviewer/
uv = {
north: [_to[0], 16 - _to[1], _from[0], 16 - _from[1]],
east: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
south: [_from[0], 16 - _to[1], _to[0], 16 - _from[1]],
west: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
up: [_from[0], _from[2], _to[0], _to[2]],
down: [_to[0], _from[2], _from[0], _to[2]]
}[sideName]
}
const su = (uv[2] - uv[0]) * face.texture.su / 16
const sv = (uv[3] - uv[1]) * face.texture.sv / 16
face.texture.bu = face.texture.u + 0.5 * face.texture.su
face.texture.bv = face.texture.v + 0.5 * face.texture.sv
face.texture.u += uv[0] * face.texture.su / 16
face.texture.v += uv[1] * face.texture.sv / 16
face.texture.su = su
face.texture.sv = sv
}
}
}
function resolveModel (name, blocksModels, texturesJson) {
const model = getModel(name, blocksModels)
prepareModel(model, texturesJson.textures)
return model
}
function prepareBlocksStates (mcAssets, atlas) {
const blocksStates = mcAssets.blocksStates
mcAssets.blocksStates["missing_texture"] = {
"variants": {
"normal": {
"model": "missing_texture"
}
}
},
mcAssets.blocksModels["missing_texture"] = {
"parent": "block/cube_all",
"textures": {
"all": "blocks/missing_texture"
}
}
for (const block of Object.values(blocksStates)) {
if (!block) continue
if (block.variants) {
for (const variant of Object.values(block.variants)) {
if (variant instanceof Array) {
for (const v of variant) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json)
}
} else {
variant.model = resolveModel(variant.model, mcAssets.blocksModels, atlas.json)
}
}
}
if (block.multipart) {
for (const variant of block.multipart) {
if (variant.apply instanceof Array) {
for (const v of variant.apply) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json)
}
} else {
variant.apply.model = resolveModel(variant.apply.model, mcAssets.blocksModels, atlas.json)
}
}
}
}
return blocksStates
}
module.exports = { prepareBlocksStates }

View file

@ -1,6 +1,6 @@
const fs = require('fs')
const { Canvas, Image } = require('canvas')
const path = require('path')
import fs from 'fs'
import path from 'path'
import { Canvas, Image } from 'canvas'
function nextPowerOfTwo (n) {
if (n === 0) return 1
@ -13,44 +13,50 @@ function nextPowerOfTwo (n) {
return n + 1
}
const localTextures = ['missing_texture.png']
function readTexture (basePath, name) {
if (name === 'missing_texture.png') {
if (localTextures.includes(name)) {
// grab ./missing_texture.png
basePath = __dirname
}
return fs.readFileSync(path.join(basePath, name), 'base64')
}
function makeTextureAtlas (mcAssets) {
export function makeTextureAtlas (mcAssets) {
const blocksTexturePath = path.join(mcAssets.directory, '/blocks')
const textureFiles = fs.readdirSync(blocksTexturePath).filter(file => file.endsWith('.png'))
textureFiles.unshift('missing_texture.png')
textureFiles.unshift(...localTextures)
const texSize = nextPowerOfTwo(Math.ceil(Math.sqrt(textureFiles.length)))
const tileSize = 16
const imgSize = texSize * tileSize
const canvas = new Canvas(imgSize, imgSize, 'png')
const canvas = new Canvas(imgSize, imgSize, 'png' as any)
const g = canvas.getContext('2d')
const texturesIndex = {}
let offset = 0
for (const i in textureFiles) {
const x = (i % texSize) * tileSize
const y = Math.floor(i / texSize) * tileSize
const pos = +i + offset
const x = (pos % texSize) * tileSize
const y = Math.floor(pos / texSize) * tileSize
const name = textureFiles[i].split('.')[0]
texturesIndex[name] = { u: x / imgSize, v: y / imgSize, su: tileSize / imgSize, sv: tileSize / imgSize }
const img = new Image()
img.src = 'data:image/png;base64,' + readTexture(blocksTexturePath, textureFiles[i])
g.drawImage(img, 0, 0, 16, 16, x, y, 16, 16)
const needsMoreWidth = img.width > tileSize
if (needsMoreWidth) {
console.log('needs more', name, img.width, img.height)
offset++
}
const renderWidth = needsMoreWidth ? tileSize * 2 : tileSize
g.drawImage(img, 0, 0, img.width, img.height, x, y, renderWidth, tileSize)
texturesIndex[name] = { u: x / imgSize, v: y / imgSize, su: renderWidth / imgSize, sv: tileSize / imgSize }
}
return { image: canvas.toBuffer(), canvas, json: { size: tileSize / imgSize, textures: texturesIndex } }
}
module.exports = {
makeTextureAtlas
}

View file

@ -0,0 +1,37 @@
import path from 'path'
import { makeTextureAtlas } from './atlas'
import { McAssets, prepareBlocksStates } from './modelsBuilder'
import mcAssets from 'minecraft-assets'
import fs from 'fs-extra'
const publicPath = path.resolve(__dirname, '../../public')
const texturesPath = path.join(publicPath, 'textures')
if (fs.existsSync(texturesPath) && !process.argv.includes('-f')) {
console.log('textures folder already exists, skipping...')
process.exit(0)
}
fs.mkdirSync(texturesPath, { recursive: true })
const blockStatesPath = path.join(publicPath, 'blocksStates')
fs.mkdirSync(blockStatesPath, { recursive: true })
Promise.resolve().then(async () => {
for (const version of mcAssets.versions) {
const assets = mcAssets(version)
// #region texture atlas
const atlas = makeTextureAtlas(assets)
const out = fs.createWriteStream(path.resolve(texturesPath, version + '.png'))
const stream = (atlas.canvas as any).pngStream()
stream.on('data', (chunk) => out.write(chunk))
stream.on('end', () => console.log('Generated textures/' + version + '.png'))
// #endregion
const blocksStates = JSON.stringify(prepareBlocksStates(assets, atlas))
fs.writeFileSync(path.resolve(blockStatesPath, version + '.json'), blocksStates)
fs.copySync(assets.directory, path.resolve(texturesPath, version), { overwrite: true })
}
fs.writeFileSync(path.join(publicPath, 'supportedVersions.json'), '[' + mcAssets.versions.map(v => `"${v}"`).toString() + ']')
})

View file

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

Before After
Before After

View file

@ -0,0 +1,251 @@
type ModelBasic = {
model: string
x?: number
y?: number
uvlock?: boolean
}
type BlockApplyModel = ModelBasic | (ModelBasic & { weight })[]
type BlockStateCondition = {
[name: string]: string | number
}
type BlockState = {
variants?: {
[name: string | ""]: BlockApplyModel
}
multipart?: {
when: {
[name: string]: string | number
} & {
OR?: BlockStateCondition[]
}
apply: BlockApplyModel
}[]
}
type BlockModel = {
parent?: string
textures?: {
[name: string]: string
}
elements?: {
from: number[]
to: number[]
faces: {
[name: string]: {
texture: string
uv?: number[]
cullface?: string
}
}
}[]
ambientocclusion?: boolean
x?: number
y?: number
z?: number
ao?: boolean
}
export type McAssets = {
blocksStates: {
[x: string]: BlockState
}
blocksModels: {
[x: string]: BlockModel
}
directory: string
version: string
}
export type BlockStatesOutput = {
// states: {
[blockName: string]: ResolvedModel
// }
// defaults: {
// su: number
// sv: number
// }
}
export type ResolvedModel = {
textures: {
[name: string]: {
u: number
v: number
su: number
sv: number
bu: number
bv: number
}
}
elements: {
from: number[]
to: number[]
faces: {
[name: string]: {
texture: {
u: number
v: number
su: number
sv: number
bu: number
bv: number
}
}
}
}[]
ao: boolean
x?: number
y?: number
z?: number
}
export const addBlockAllModel = (mcAssets: McAssets, name: string, texture = name) => {
mcAssets.blocksStates[name] = {
"variants": {
"": {
"model": name
}
}
}
mcAssets.blocksModels[name] = {
"parent": "block/cube_all",
"textures": {
"all": `blocks/${texture}`
}
}
}
function cleanupBlockName (name: string) {
if (name.startsWith('block') || name.startsWith('minecraft:block')) return name.split('/')[1]
return name
}
const objectAssignStrict = <T extends Record<string, any>>(target: T, source: Partial<T>) => Object.assign(target, source)
function getFinalModel (name: string, blocksModels: { [x: string]: BlockModel }) {
name = cleanupBlockName(name)
const input = blocksModels[name]
if (!input) {
return null
}
let out: BlockModel | null = {
textures: {},
elements: [],
ao: true,
x: input.x,
y: input.y,
z: input.z,
}
if (input.parent) {
out = getFinalModel(input.parent, blocksModels)
if (!out) return null
}
if (input.textures) {
Object.assign(out.textures!, deepCopy(input.textures))
}
if (input.elements) out.elements = deepCopy(input.elements)
if (input.ao !== undefined) out.ao = input.ao
return out
}
const deepCopy = (obj) => JSON.parse(JSON.stringify(obj))
function prepareModel (model: BlockModel, texturesJson) {
const newModel = {}
const getFinalTexture = (originalBlockName) => {
// texture name e.g. blocks/anvil_base
const cleanBlockName = cleanupBlockName(originalBlockName);
return {...texturesJson[cleanBlockName], __debugName: cleanBlockName}
}
const finalTextures = []
// resolve texture names eg west: #all -> blocks/stone
for (const side in model.textures) {
let texture = model.textures[side]
while (texture.charAt(0) === '#') {
texture = model.textures[texture.slice(1)]
}
finalTextures[side] = getFinalTexture(texture)
}
for (const elem of model.elements!) {
for (const sideName of Object.keys(elem.faces)) {
const face = elem.faces[sideName]
const finalTexture = deepCopy(
face.texture.charAt(0) === '#'
? finalTextures![face.texture.slice(1)]
: getFinalTexture(face.texture)
)
const _from = elem.from
const _to = elem.to
// taken from https://github.com/DragonDev1906/Minecraft-Overviewer/
const uv = face.uv || {
// default UVs
// format: [u1, v1, u2, v2] (u = x, v = y)
north: [_to[0], 16 - _to[1], _from[0], 16 - _from[1]],
east: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
south: [_from[0], 16 - _to[1], _to[0], 16 - _from[1]],
west: [_from[2], 16 - _to[1], _to[2], 16 - _from[1]],
up: [_from[0], _from[2], _to[0], _to[2]],
down: [_to[0], _from[2], _from[0], _to[2]]
}[sideName]!
const su = (uv[2] - uv[0]) / 16 * finalTexture.su
const sv = (uv[3] - uv[1]) / 16 * finalTexture.sv
finalTexture.u += uv[0] / 16 * finalTexture.su
finalTexture.v += uv[1] / 16 * finalTexture.sv
finalTexture.su = su
finalTexture.sv = sv
face.texture = finalTexture
}
}
return model
}
function resolveModel (name, blocksModels, texturesJson) {
const model = getFinalModel(name, blocksModels)
return prepareModel(model, texturesJson.textures)
}
export function prepareBlocksStates (mcAssets: McAssets, atlas: { json: any }) {
addBlockAllModel(mcAssets, 'missing_texture')
const blocksStates = mcAssets.blocksStates
for (const block of Object.values(blocksStates)) {
if (!block) continue
if (block.variants) {
for (const variant of Object.values(block.variants)) {
if (variant instanceof Array) {
for (const v of variant) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json) as any
}
} else {
variant.model = resolveModel(variant.model, mcAssets.blocksModels, atlas.json) as any
}
}
}
if (block.multipart) {
for (const variant of block.multipart) {
if (variant.apply instanceof Array) {
for (const v of variant.apply) {
v.model = resolveModel(v.model, mcAssets.blocksModels, atlas.json) as any
}
} else {
variant.apply.model = resolveModel(variant.apply.model, mcAssets.blocksModels, atlas.json) as any
}
}
}
}
return blocksStates
}