├── test-data └── .gitignore ├── .gitignore ├── docs ├── soul_campfire.png └── soul_campfire_small.png ├── src ├── index.ts ├── tests │ ├── minecraft.test.ts │ ├── jar.test.ts │ ├── render.test.ts │ └── download.test.ts ├── utils │ ├── vector-math.ts │ ├── jar.ts │ ├── logger.ts │ ├── types.ts │ └── apng.ts ├── minecraft.ts └── render.ts ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── .vscode └── launch.json ├── LICENSE.md ├── package.json ├── bin └── minecraft-render.js └── README.md /test-data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | output 4 | *.jar 5 | -------------------------------------------------------------------------------- /docs/soul_campfire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/co3moz/minecraft-render/HEAD/docs/soul_campfire.png -------------------------------------------------------------------------------- /docs/soul_campfire_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/co3moz/minecraft-render/HEAD/docs/soul_campfire_small.png -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Minecraft } from './minecraft'; 2 | export { Jar } from './utils/jar'; 3 | export { Logger } from './utils/logger'; 4 | export * from './utils/types'; -------------------------------------------------------------------------------- /src/tests/minecraft.test.ts: -------------------------------------------------------------------------------- 1 | import { Dependency, Spec } from 'nole'; 2 | import { Minecraft } from '../minecraft'; 3 | import { JarTest } from './jar.test'; 4 | 5 | export class MinecraftTest { 6 | @Dependency(JarTest) 7 | jarTest!: JarTest; 8 | 9 | minecraft!: Minecraft 10 | 11 | @Spec() 12 | async init() { 13 | this.minecraft = Minecraft.open(this.jarTest.jar); 14 | } 15 | 16 | @Spec() 17 | async blockModel() { 18 | (await this.minecraft.getModelFile()); 19 | } 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2017", 7 | "esnext" 8 | ], 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | "removeComments": true, 14 | "strict": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true 17 | }, 18 | "include": [ 19 | "src/**/*.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist" 24 | ], 25 | } -------------------------------------------------------------------------------- /src/utils/vector-math.ts: -------------------------------------------------------------------------------- 1 | import { Vector } from './types'; 2 | 3 | export function size(from: Vector, to: Vector) { 4 | return [ 5 | to[0] - from[0], 6 | to[1] - from[1], 7 | to[2] - from[2] 8 | ] as const; 9 | } 10 | 11 | export function invert(v: Vector) { 12 | return [ 13 | -v[0], 14 | -v[1], 15 | -v[2], 16 | ] as const; 17 | } 18 | 19 | export function mul(v: Vector, f: number) { 20 | return [ 21 | v[0] * f, 22 | v[1] * f, 23 | v[2] * f, 24 | ] as const; 25 | } 26 | 27 | export function distance(v: Vector) { 28 | return Math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2); 29 | } -------------------------------------------------------------------------------- /src/tests/jar.test.ts: -------------------------------------------------------------------------------- 1 | import { Dependency, Hook, HookType, Spec } from 'nole'; 2 | import { Jar } from '../utils/jar'; 3 | import * as path from 'path'; 4 | import { DownloadTest } from './download.test'; 5 | 6 | export class JarTest { 7 | @Dependency(DownloadTest) 8 | downloadTest!: DownloadTest; 9 | 10 | jar!: Jar; 11 | 12 | @Spec() 13 | async init() { 14 | this.jar = Jar.open(this.downloadTest.jarPath); 15 | } 16 | 17 | @Spec() 18 | async entries() { 19 | await this.jar.entries('assets'); 20 | } 21 | 22 | @Hook(HookType.CleanUp) 23 | async cleanUp() { 24 | await this.jar.close(); 25 | } 26 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 10 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '14' 14 | cache: 'npm' 15 | - run: npm install 16 | - run: sudo apt-get install xvfb 17 | - run: xvfb-run --auto-servernum npm test 18 | - name: Archive production artifacts 19 | uses: actions/upload-artifact@v2 20 | with: 21 | name: test-result 22 | path: | 23 | test-data/* 24 | !test-data/test.jar 25 | !test-data/.gitignore -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch via NPM", 9 | "request": "launch", 10 | "runtimeArgs": [ 11 | "run-script", 12 | "test" 13 | ], 14 | "runtimeExecutable": "npm", 15 | "skipFiles": [ 16 | "/**" 17 | ], 18 | "type": "pwa-node", 19 | "env": { 20 | "BLOCK_NAMES": "sea_lantern,observer,command_block,slime_block,lectern,blast_furnace_on,prismarine", 21 | "RENDER_FOLDER": "render/", 22 | "LOGGER_LEVEL": "trace" 23 | } 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /src/utils/jar.ts: -------------------------------------------------------------------------------- 1 | import { async, ZipEntry } from 'node-stream-zip'; 2 | 3 | export class Jar { 4 | protected zip: InstanceType; 5 | 6 | protected constructor(public file: string) { 7 | this.zip = new async({ file }); 8 | } 9 | 10 | static open(file: string) { 11 | return new Jar(file); 12 | } 13 | 14 | async close() { 15 | await this.zip.close(); 16 | } 17 | 18 | 19 | async entries(path: string): Promise { 20 | return Object.entries(await this.zip.entries()) 21 | .filter(([key]) => key.startsWith(path)) 22 | .map(([_, value]) => value); 23 | } 24 | 25 | read(path: string | ZipEntry) { 26 | return this.zip.entryData(typeof path === "string" ? path : path.name); 27 | } 28 | 29 | async readJson(path: string | ZipEntry) { 30 | return JSON.parse((await this.read(path)).toString()); 31 | } 32 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 co3moz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | // Simple logging utility 2 | 3 | const CATEGORIES = { 4 | error: 1, 5 | warn: 2, 6 | info: 3, 7 | debug: 4, 8 | trace: 5 9 | } as const; 10 | 11 | export class Logger { 12 | public static get categories() { 13 | return CATEGORIES; 14 | } 15 | 16 | public static level = getLevelFromEnv() ?? CATEGORIES.info; 17 | 18 | static log(level: keyof typeof CATEGORIES, fn: LoggerCallback) { 19 | if (Logger.level >= CATEGORIES[level]) { 20 | console.log(fn()); 21 | } 22 | } 23 | 24 | static error(fn: LoggerCallback) { 25 | this.log('error', fn); 26 | } 27 | 28 | static warn(fn: LoggerCallback) { 29 | this.log('warn', fn); 30 | } 31 | 32 | static info(fn: LoggerCallback) { 33 | this.log('info', fn); 34 | } 35 | 36 | static debug(fn: LoggerCallback) { 37 | this.log('debug', fn); 38 | } 39 | 40 | static trace(fn: LoggerCallback) { 41 | this.log('trace', fn); 42 | } 43 | } 44 | 45 | export interface LoggerCallback { 46 | (): string 47 | } 48 | 49 | function getLevelFromEnv() { 50 | return (process.env.LOGGER_LEVEL && CATEGORIES[process.env.LOGGER_LEVEL as keyof typeof CATEGORIES]); 51 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-render", 3 | "version": "1.1.1", 4 | "description": "Minecraft gui block/item renderer", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "nole ./src/**/*.test.ts" 10 | }, 11 | "bin": { 12 | "minecraft-render": "./bin/minecraft-render.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/co3moz/minecraft-render.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/co3moz/minecraft-render/issues" 20 | }, 21 | "homepage": "https://github.com/co3moz/minecraft-render#readme", 22 | "keywords": [ 23 | "minecraft", 24 | "render", 25 | "block", 26 | "item" 27 | ], 28 | "author": "co3moz", 29 | "license": "MIT", 30 | "dependencies": { 31 | "assign-deep": "^1.0.1", 32 | "canvas": "^2.8.0", 33 | "commander": "^8.1.0", 34 | "crc": "^3.8.0", 35 | "node-canvas-webgl": "^0.2.6", 36 | "node-stream-zip": "^1.13.6", 37 | "three": "^0.130.1" 38 | }, 39 | "devDependencies": { 40 | "@types/crc": "^3.4.0", 41 | "@types/node-fetch": "^2.5.12", 42 | "@types/three": "^0.130.1", 43 | "mkdirp": "^1.0.4", 44 | "node-fetch": "^2.6.1", 45 | "nole": "^1.0.12", 46 | "typescript": "^4.3.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/tests/render.test.ts: -------------------------------------------------------------------------------- 1 | import { Dependency, Skip, Spec } from 'nole'; 2 | import { MinecraftTest } from './minecraft.test'; 3 | 4 | import * as path from 'path'; 5 | import * as fs from 'fs'; 6 | import { BlockModel } from '../utils/types'; 7 | import { Logger } from '../utils/logger'; 8 | 9 | 10 | export class RenderTest { 11 | @Dependency(MinecraftTest) 12 | minecraftTest!: MinecraftTest; 13 | 14 | @Spec(180000) 15 | async renderAll() { 16 | const blocks = await this.minecraftTest.minecraft.getBlockList(); 17 | 18 | const renderCandidates = pickBlocks(blocks); 19 | 20 | for await (const render of this.minecraftTest.minecraft.render(renderCandidates)) { 21 | if (!render.buffer) { 22 | console.log('Rendering skipped ' + render.blockName + ' reason: ' + render.skip!); 23 | continue; 24 | } 25 | 26 | const filePath = path.resolve(__dirname, `../../test-data/${process.env.RENDER_FOLDER || ''}${render.blockName}.png`); 27 | 28 | await writeAsync(filePath, render.buffer); 29 | } 30 | } 31 | } 32 | 33 | function writeAsync(filePath: string, buffer: Buffer) { 34 | return new Promise((resolve, reject) => { 35 | fs.writeFile(filePath, buffer, (err) => { 36 | if (err) reject(err); 37 | else resolve(); 38 | }); 39 | }); 40 | } 41 | 42 | function pickBlocks(blocks: BlockModel[]) { 43 | const { BLOCK_NAMES } = process.env; 44 | 45 | if (!BLOCK_NAMES) { 46 | return blocks; 47 | } 48 | 49 | const preferred = BLOCK_NAMES.split(','); 50 | 51 | Logger.info(() => `BLOCK_NAMES flag is enabled. "${BLOCK_NAMES}"`) 52 | 53 | return blocks.filter(block => preferred.some(name => name == block.blockName)); 54 | } -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type * as THREE from 'three'; 2 | import type * as rawCanvas from 'canvas'; 3 | 4 | export type UnwrapPromise = T extends PromiseLike ? U : T 5 | export type UnwrapArray = T extends Array ? U : T 6 | 7 | export type Vector = readonly [number, number, number]; 8 | export type Vector4 = readonly [number, number, number, number]; 9 | 10 | export interface Transform { 11 | rotation: Vector 12 | translation: Vector 13 | scale: Vector 14 | } 15 | 16 | export interface Rotation { 17 | angle?: number 18 | axis?: string 19 | origin?: Vector 20 | } 21 | 22 | export type BlockFaces = 'north' | 'south' | 'east' | 'west' | 'up' | 'down'; 23 | export type BlockSides = 'all' | 'top' | 'bottom' | 'side' | 'front' | 'particle' | 'pane' | 'wood' | 'back' | BlockFaces; 24 | 25 | export interface BlockModel { 26 | blockName?: string 27 | parents?: string[] 28 | animationMaxTicks?: number 29 | animationCurrentTick?: number 30 | 31 | 32 | parent?: string 33 | textures?: { 34 | [key in BlockSides]?: string 35 | } 36 | gui_light?: "front" | "side", 37 | display?: { 38 | gui?: Transform, 39 | ground?: Transform 40 | fixed?: Transform 41 | thirdperson_righthand?: Transform 42 | firstperson_righthand?: Transform 43 | firstperson_lefthand?: Transform 44 | } 45 | elements?: Element[] 46 | } 47 | 48 | export interface Element { 49 | from?: Vector 50 | to?: Vector 51 | rotation?: Rotation 52 | faces?: { 53 | [key in BlockFaces]?: Face 54 | } 55 | 56 | calculatedSize?: Vector 57 | } 58 | 59 | export interface Face { 60 | uv?: Vector4, 61 | texture: string, 62 | rotation?: number 63 | cullface?: string 64 | } 65 | 66 | export interface Renderer { 67 | scene: THREE.Scene 68 | renderer: THREE.WebGLRenderer 69 | canvas: rawCanvas.Canvas 70 | camera: THREE.OrthographicCamera 71 | textureCache: { [key: string]: any } 72 | animatedCache: { [key: string]: AnimationMeta | null } 73 | options: RendererOptions 74 | } 75 | 76 | export type AnimationMeta = { 77 | interpolate?: boolean // Generate additional frames between keyframes where frametime > 1 78 | width?: number //Custom dimensions for none square textures, unused in vanilla 79 | height?: number, 80 | frametime?: number // Frame time in game ticks, default is 1 81 | frames?: (number|{ index: number, time: number})[] 82 | } 83 | 84 | export interface RendererOptions { 85 | width?: number 86 | height?: number 87 | distance?: number 88 | verbose?: number 89 | plane?: number 90 | animation?: boolean 91 | } -------------------------------------------------------------------------------- /src/tests/download.test.ts: -------------------------------------------------------------------------------- 1 | import { Spec, skipTest } from "nole"; 2 | import fetch from 'node-fetch'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | 6 | export class DownloadTest { 7 | private targetVersionUrl: string = ''; 8 | private jarUrl: string = ''; 9 | public jarPath: string = ''; 10 | 11 | @Spec() 12 | async getManifest() { 13 | this.checkExistingJar(); 14 | const response = await fetch(`https://launchermeta.mojang.com/mc/game/version_manifest.json`) 15 | const manifest: VersionManifest = await response.json(); 16 | this.targetVersionUrl = manifest.versions.find(version => version.type == 'release' || version.id == manifest.latest.release)!.url; 17 | } 18 | 19 | @Spec() 20 | async getVersionJarUrl() { 21 | this.checkExistingJar(); 22 | const response = await fetch(this.targetVersionUrl) 23 | const version: Version = await response.json(); 24 | this.jarUrl = version.downloads.client.url; 25 | } 26 | 27 | @Spec(120000) 28 | async downloadJar() { 29 | this.checkExistingJar(); 30 | const response = await fetch(this.jarUrl); 31 | 32 | this.jarPath = this.getPath(); 33 | 34 | const stream = fs.createWriteStream(this.jarPath); 35 | 36 | await new Promise((resolve, reject) => { 37 | response.body.pipe(stream) 38 | response.body.on('error', reject); 39 | stream.on('close', resolve); 40 | }); 41 | } 42 | 43 | checkExistingJar(): void | never { 44 | if (this.jarPath) skipTest('Jar already exists'); 45 | const checkPath = this.getPath(); 46 | if (fs.existsSync(checkPath)) { 47 | this.jarPath = checkPath; 48 | skipTest('Jar already exists'); 49 | } 50 | } 51 | 52 | getPath() { 53 | return path.resolve(__dirname, '../../test-data/test.jar'); 54 | } 55 | } 56 | 57 | interface VersionManifest { 58 | latest: { release: string, snapshot: string } 59 | versions: { 60 | id: string, 61 | type: 'release' | 'snapshot' 62 | url: string 63 | time: string 64 | releaseTime: string 65 | }[] 66 | } 67 | 68 | interface Version { 69 | arguments: any 70 | assetIndex: any 71 | assets: string 72 | downloads: { 73 | client: DownloadInfo 74 | client_mappings: DownloadInfo 75 | server: DownloadInfo 76 | } 77 | id: string 78 | javaVersion: any 79 | libraries: { downloads: any, name: string }[], 80 | logging: any 81 | mainClass: string 82 | minimumLauncherVersion: number 83 | releaseTime: string 84 | time: string 85 | type: 'release' | 'snapshot' 86 | } 87 | 88 | interface DownloadInfo { 89 | sha1: string 90 | size: number 91 | url: string 92 | } 93 | -------------------------------------------------------------------------------- /bin/minecraft-render.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const package = require('../package.json'); 7 | const mkdirp = require('mkdirp'); 8 | const { Minecraft, Logger } = require('../dist'); 9 | 10 | program 11 | .usage(' [output]') 12 | .option('-w, --width [width]', 'output image width', 1000) 13 | .option('-t, --height [height]', 'output image height', 1000) 14 | .option('-d, --distance [distance]', 'distance between camera and block', 20) 15 | .option('-v, --verbose', 'increases logging level', (v, p) => typeof v != 'undefined' ? v : (p + 1), Logger.categories.info) 16 | .option('-p, --plane', 'debugging plane and axis', 0) 17 | .option('-A, --no-animation', 'disables apng generation') 18 | .option('-f, --filter ', 'regex pattern to filter blocks by name') 19 | .version(package.version) 20 | .parse(process.argv); 21 | 22 | const options = program.opts(); 23 | 24 | if (!program.args.length) { 25 | return program.help(); 26 | } 27 | 28 | async function Main() { 29 | Logger.level = options.verbose; 30 | 31 | const minecraft = Minecraft.open(path.resolve(program.args[0])); 32 | const blocks = filterByRegex(options.filter, await minecraft.getBlockList()); 33 | 34 | let i = 0; 35 | const folder = path.resolve(program.args[1] || 'output'); 36 | 37 | await mkdirp(folder); 38 | 39 | const padSize = Math.ceil(Math.log10(blocks.length)); 40 | const totalBlocks = blocks.length.toString().padStart(padSize, '0'); 41 | 42 | const rendererOptions = { 43 | height: parseInt(options.height), 44 | width: parseInt(options.width), 45 | distance: parseInt(options.distance), 46 | plane: options.plane, 47 | animation: options.animation 48 | }; 49 | 50 | for await (const block of minecraft.render(blocks, rendererOptions)) { 51 | const j = (++i).toString().padStart(padSize, '0'); 52 | 53 | if (!block.buffer) { 54 | console.log(`[${j} / ${totalBlocks}] ${block.blockName} skipped due to "${block.skip}"`); 55 | continue; 56 | } 57 | 58 | const filePath = path.join(folder, block.blockName + '.png'); 59 | await fs.promises.writeFile(filePath, block.buffer); 60 | 61 | console.log(`[${j} / ${totalBlocks}] ${block.blockName} rendered to ${filePath}`); 62 | } 63 | 64 | console.log(`Rendering completed! "${folder}"`); 65 | } 66 | 67 | function filterByRegex(pattern, array) { 68 | if (!pattern) return array; 69 | 70 | const regex = new RegExp(pattern); 71 | 72 | return array.filter(block => regex.test(block.blockName)); 73 | } 74 | 75 | Main().catch(e => console.error('Rendering failed!', e)); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Rendered image](https://raw.githubusercontent.com/co3moz/minecraft-render/master/docs/soul_campfire_small.png)](https://github.com/co3moz/minecraft-render/blob/master/docs/soul_campfire.png) 2 | 3 | minecraft-render 4 | ======================= 5 | 6 | 7 | Renders minecraft block models from .jar file using `THREE.js`. 8 | Default output format is PNG `1000x1000`. 9 | 10 | 11 | ### Pre-rendered assets 12 | 13 | You can find pre-rendered assets on Github Actions artifacts. By clicking the badge down below, you can access action list. 14 | 15 | [![Render Test](https://github.com/co3moz/minecraft-render/actions/workflows/ci.yml/badge.svg)](https://github.com/co3moz/minecraft-render/actions/workflows/ci.yml) 16 | 17 | 18 | 19 | ### Binaries 20 | 21 | Basic usage; 22 | 23 | ```sh 24 | npx minecraft-render 25 | 26 | 27 | Usage: minecraft-render [output] 28 | 29 | Options: 30 | -w, --width [width] output image width (default: 1000) 31 | -t, --height [height] output image height (default: 1000) 32 | -d, --distance [distance] distance between camera and block (default: 20) 33 | -v, --verbose increases logging level (default: 3) 34 | -p, --plane debugging plane and axis (default: 0) 35 | -A, --no-animation disables apng generation 36 | -f, --filter regex pattern to filter blocks by name 37 | -V, --version output the version number 38 | -h, --help display help for command 39 | ``` 40 | 41 | ```sh 42 | npx minecraft-render minecraft-version.1.17.1.jar output-folder/ 43 | 44 | 45 | ... 46 | [0168 / 1710] observer rendered to output-folder\observer.png 47 | [0169 / 1710] comparator_on_subtract skipped due to "no gui" 48 | [0170 / 1710] template_trapdoor_open skipped due to "no gui" 49 | ... 50 | ``` 51 | 52 | Filtering and rendering options 53 | 54 | 55 | ```sh 56 | npx minecraft-render minecraft-version.1.17.1.jar --filter "soul_campfire" --no-animation --width 100 --height 100 output/ --verbose 57 | 58 | 59 | [1 / 1] soul_campfire rendered to output-folder\soul_campfire.png 60 | ``` 61 | 62 | 63 | ### Using Rendering API 64 | 65 | ```ts 66 | import { Minecraft } from 'minecraft-render'; 67 | import fs from 'fs'; 68 | 69 | async function main() { 70 | const minecraft = Minecraft.open('./minecraft-version.1.17.1.jar'); 71 | const blocks = await minecraft.getBlockList(); 72 | 73 | for await (const block of minecraft.render(blocks)) { 74 | if (!block.buffer) { 75 | console.log(`${block.blockName} skipped due to ${block.skip}`); 76 | continue; 77 | } 78 | 79 | await fs.promises.writeFile(`./render/${block.blockName}.png`, block.buffer); 80 | } 81 | } 82 | ``` 83 | 84 | 85 | ### Tests 86 | 87 | Current test configuration is capable of downloading jar files from mojang servers and execute render sequence. You can trigger tests with; 88 | 89 | ```sh 90 | npm test 91 | ``` 92 | 93 | ### Headless render and CI 94 | 95 | If you are automating generation process on github or similar CI environments, make sure you configured display server. `xvfb` can be used for this purpose. 96 | 97 | ```sh 98 | sudo apt-get install xvfb 99 | xvfb-run --auto-servernum minecraft-render ... 100 | ``` 101 | -------------------------------------------------------------------------------- /src/utils/apng.ts: -------------------------------------------------------------------------------- 1 | import { crc32 } from 'crc'; 2 | 3 | // https://github.com/TomasHubelbauer/node-apng/blob/master/index.js 4 | export function makeAnimatedPNG(buffers: Buffer[], delay: DelayFn) { 5 | const actl = Buffer.alloc(20); 6 | actl.writeUInt32BE(8, 0); // Length of chunk 7 | actl.write('acTL', 4); // Type of chunk 8 | actl.writeUInt32BE(buffers.length, 8); // Number of frames 9 | actl.writeUInt32BE(0, 12); // Number of times to loop (0 - infinite) 10 | actl.writeUInt32BE(crc32(actl.slice(4, 16)), 16); // CRC 11 | 12 | let sequenceNumber = 0; 13 | const frames = buffers.map((data, index) => { 14 | const ihdr = findChunk(data, 'IHDR'); 15 | 16 | if (ihdr === null) { 17 | throw new Error('IHDR chunk not found!'); 18 | } 19 | 20 | const fctl = Buffer.alloc(38); 21 | fctl.writeUInt32BE(26, 0); // Length of chunk 22 | fctl.write('fcTL', 4); // Type of chunk 23 | fctl.writeUInt32BE(sequenceNumber++, 8); // Sequence number 24 | fctl.writeUInt32BE(ihdr.readUInt32BE(8), 12); // Width 25 | fctl.writeUInt32BE(ihdr.readUInt32BE(12), 16); // Height 26 | fctl.writeUInt32BE(0, 20); // X offset 27 | fctl.writeUInt32BE(0, 24); // Y offset 28 | const { numerator, denominator } = delay(index); 29 | fctl.writeUInt16BE(numerator, 28); // Frame delay - fraction numerator 30 | fctl.writeUInt16BE(denominator, 30); // Frame delay - fraction denominator 31 | fctl.writeUInt8(0, 32); // Dispose mode 32 | fctl.writeUInt8(0, 33); // Blend mode 33 | fctl.writeUInt32BE(crc32(fctl.slice(4, 34)), 34); // CRC 34 | 35 | let offset = 8; 36 | const fdats = []; 37 | while (true) { 38 | const idat = findChunk(data, 'IDAT', offset); 39 | if (idat === null) { 40 | if (offset === 8) { 41 | throw new Error('No IDAT chunks found!'); 42 | } 43 | else { 44 | break; 45 | } 46 | } 47 | 48 | offset = idat.byteOffset + idat.length; 49 | 50 | // All IDAT chunks except first one are converted to fdAT chunks 51 | if (index === 0) { 52 | fdats.push(idat); 53 | } else { 54 | const length = idat.length + 4; 55 | const fdat = Buffer.alloc(length); 56 | fdat.writeUInt32BE(length - 12, 0); // Length of chunk 57 | fdat.write('fdAT', 4); // Type of chunk 58 | fdat.writeUInt32BE(sequenceNumber++, 8); // Sequence number 59 | idat.copy(fdat, 12, 8); // Image data 60 | fdat.writeUInt32BE(crc32(fdat.slice(4, length - 4)), length - 4); // CRC 61 | fdats.push(fdat); 62 | } 63 | } 64 | 65 | return Buffer.concat([fctl, ...fdats]); 66 | }); 67 | 68 | const signature = Buffer.from('89504e470d0a1a0a', 'hex'); 69 | const ihdr = findChunk(buffers[0], 'IHDR'); 70 | if (ihdr === null) { 71 | throw new Error('IHDR chunk not found!'); 72 | } 73 | 74 | const iend = Buffer.from('0000000049454e44ae426082', 'hex'); 75 | return Buffer.concat([signature, ihdr, actl, ...frames, iend]); 76 | } 77 | 78 | function findChunk(buffer: Buffer, type: string, offset = 8) { 79 | while (offset < buffer.length) { 80 | const chunkLength = buffer.readUInt32BE(offset); 81 | const chunkType = buffer.slice(offset + 4, offset + 8).toString('ascii'); 82 | 83 | if (chunkType === type) { 84 | return buffer.slice(offset, offset + chunkLength + 12); 85 | } 86 | 87 | offset += 4 + 4 + chunkLength + 4; 88 | } 89 | 90 | return null; 91 | } 92 | 93 | export interface DelayFn { 94 | (frameIndex: number): ({ numerator: number, denominator: number }); 95 | } -------------------------------------------------------------------------------- /src/minecraft.ts: -------------------------------------------------------------------------------- 1 | import { destroyRenderer, prepareRenderer, render } from "./render"; 2 | import { Jar } from "./utils/jar"; 3 | import type { AnimationMeta, BlockModel, Renderer, RendererOptions } from "./utils/types"; 4 | //@ts-ignore 5 | import * as deepAssign from 'assign-deep'; 6 | 7 | export class Minecraft { 8 | protected jar: Jar 9 | protected renderer!: Renderer | null; 10 | protected _cache: { [key: string]: any } = {}; 11 | 12 | protected constructor(public file: string | Jar) { 13 | if (file instanceof Jar) { 14 | this.jar = file; 15 | } else { 16 | this.jar = Jar.open(file); 17 | } 18 | } 19 | 20 | static open(file: string | Jar) { 21 | return new Minecraft(file); 22 | } 23 | 24 | async getBlockNameList(): Promise { 25 | return (await this.jar.entries('assets/minecraft/models/block')) 26 | .filter(entry => entry.name.endsWith(".json")) 27 | .map(entry => entry.name.slice('assets/minecraft/models/block/'.length, -('.json'.length))); 28 | } 29 | 30 | async getBlockList(): Promise { 31 | return await Promise.all((await this.getBlockNameList()).map(block => this.getModel(block))); 32 | } 33 | 34 | async getModelFile(name = 'block/block'): Promise { 35 | if (name.startsWith('minecraft:')) { 36 | name = name.substring('minecraft:'.length); 37 | } 38 | 39 | if (name.indexOf('/') == -1) { 40 | name = `block/${name}`; 41 | } 42 | 43 | const path = `assets/minecraft/models/${name}.json`; 44 | 45 | try { 46 | if (this._cache[path]) { 47 | return JSON.parse(JSON.stringify(this._cache[path])); 48 | } 49 | 50 | this._cache[path] = await this.jar.readJson(path); 51 | 52 | return this._cache[path]; 53 | } catch (e) { 54 | throw new Error(`Unable to find model file: ${path}`); 55 | } 56 | } 57 | 58 | async getTextureFile(name: string = '') { 59 | name = name ?? ''; 60 | if (name.startsWith('minecraft:')) { 61 | name = name.substring('minecraft:'.length); 62 | } 63 | 64 | const path = `assets/minecraft/textures/${name}.png`; 65 | 66 | try { 67 | return await this.jar.read(path); 68 | } catch (e) { 69 | throw new Error(`Unable to find texture file: ${path}`); 70 | } 71 | } 72 | 73 | 74 | async getTextureMetadata(name: string = ''): Promise { 75 | name = name ?? ''; 76 | if (name.startsWith('minecraft:')) { 77 | name = name.substring('minecraft:'.length); 78 | } 79 | 80 | const path = `assets/minecraft/textures/${name}.png.mcmeta`; 81 | 82 | try { 83 | return await this.jar.readJson(path); 84 | } catch (e) { 85 | return null; 86 | } 87 | } 88 | 89 | async *render(blocks: BlockModel[], options?: RendererOptions) { 90 | try { 91 | await this.prepareRenderEnvironment(options); 92 | 93 | for (const block of blocks) { 94 | yield await render(this, block); 95 | } 96 | } finally { 97 | await this.cleanupRenderEnvironment(); 98 | } 99 | } 100 | 101 | async getModel(blockName: string): Promise { 102 | let { parent, ...model } = await this.getModelFile(blockName); 103 | 104 | if (parent) { 105 | model = deepAssign({}, await this.getModel(parent), model); 106 | 107 | if (!model.parents) { 108 | model.parents = []; 109 | } 110 | 111 | model.parents.push(parent); 112 | } 113 | 114 | return deepAssign(model, { blockName }); 115 | } 116 | 117 | async close() { 118 | await this.jar.close(); 119 | } 120 | 121 | async prepareRenderEnvironment(options: RendererOptions = {}) { 122 | this.renderer = await prepareRenderer(options) 123 | } 124 | 125 | async cleanupRenderEnvironment() { 126 | await destroyRenderer(this.renderer!); 127 | this.renderer = null; 128 | } 129 | 130 | getRenderer() { 131 | return this.renderer!; 132 | } 133 | } -------------------------------------------------------------------------------- /src/render.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import * as rawCanvas from 'canvas'; 3 | import type { BlockModel, BlockSides, Element, Face, Renderer, RendererOptions } from './utils/types'; 4 | import type { Minecraft } from './minecraft'; 5 | import { distance, invert, mul, size } from './utils/vector-math'; 6 | import { Logger } from './utils/logger'; 7 | //@ts-ignore 8 | import { createCanvas, loadImage } from 'node-canvas-webgl'; 9 | import { makeAnimatedPNG } from './utils/apng'; 10 | 11 | const MATERIAL_FACE_ORDER = ['east', 'west', 'up', 'down', 'south', 'north'] as const; 12 | 13 | export async function prepareRenderer({ width = 1000, height = 1000, distance = 20, plane = 0, animation = true }: RendererOptions): Promise { 14 | const scene = new THREE.Scene(); 15 | 16 | const canvas: rawCanvas.Canvas = createCanvas(width, height); 17 | 18 | Logger.debug(() => `prepareRenderer(width=${width}, height=${height}, distance=${distance})`); 19 | 20 | const renderer = new THREE.WebGLRenderer({ 21 | canvas: (canvas as any), 22 | antialias: true, 23 | alpha: true, 24 | logarithmicDepthBuffer: true, 25 | }); 26 | 27 | Logger.trace(() => `WebGL initialized`); 28 | 29 | renderer.sortObjects = false; 30 | 31 | const aspect = width / height; 32 | const camera = new THREE.OrthographicCamera(- distance * aspect, distance * aspect, distance, - distance, 0.01, 20000); 33 | 34 | const light = new THREE.DirectionalLight(0xFFFFFF, 1.2); 35 | light.position.set(-15, 30, -25); // cube directions x => negative:bottom right, y => positive:top, z => negative:bottom left 36 | scene.add(light); 37 | 38 | Logger.trace(() => `Light added to scene`); 39 | 40 | if (plane) { 41 | const origin = new THREE.Vector3(0, 0, 0); 42 | const length = 10; 43 | scene.add(new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), origin, length, 0xff0000)); 44 | scene.add(new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), origin, length, 0x00ff00)); 45 | scene.add(new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), origin, length, 0x0000ff)); 46 | 47 | const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 3); 48 | const helper = new THREE.PlaneHelper(plane, 30, 0xffff00); 49 | scene.add(helper); 50 | 51 | Logger.debug(() => `Plane added to scene`); 52 | } 53 | 54 | return { 55 | scene, 56 | renderer, 57 | canvas, 58 | camera, 59 | textureCache: {}, 60 | animatedCache: {}, 61 | options: { width, height, distance, plane, animation } 62 | }; 63 | } 64 | 65 | export async function destroyRenderer(renderer: Renderer) { 66 | Logger.debug(() => `Renderer destroy in progress...`); 67 | 68 | await new Promise(resolve => setTimeout(resolve, 500)); 69 | renderer.renderer.info.reset(); 70 | (renderer.canvas as any).__gl__.getExtension('STACKGL_destroy_context').destroy(); 71 | 72 | Logger.debug(() => `Renderer destroyed`); 73 | } 74 | 75 | 76 | export async function render(minecraft: Minecraft, block: BlockModel): Promise { 77 | const { canvas, renderer, scene, camera, options } = minecraft.getRenderer()!; 78 | const resultBlock: BlockModel & { buffer: Buffer, skip?: string } = block as any; 79 | 80 | const gui = block.display?.gui; 81 | 82 | if (!gui || !block.elements || !block.textures) { 83 | resultBlock.skip = !gui ? 'no gui' : (!block.elements ? 'no element' : 'no texture'); 84 | return resultBlock; 85 | } 86 | 87 | Logger.trace(() => `Started rendering ${resultBlock.blockName}`); 88 | 89 | camera.zoom = 1.0 / distance(gui.scale); 90 | 91 | Logger.trace(() => `Camera zoom = ${camera.zoom}`); 92 | 93 | if (typeof block.animationCurrentTick == 'undefined') { 94 | block.animationCurrentTick = 0; 95 | } 96 | 97 | // block.elements!.reverse(); 98 | 99 | const buffers = []; 100 | 101 | do { 102 | Logger.trace(() => `Frame[${block.animationCurrentTick}] started`); 103 | 104 | const clean = []; 105 | let i = 0; 106 | 107 | Logger.trace(() => `Element count = ${block.elements!.length}`); 108 | 109 | for (const element of block.elements!) { 110 | Logger.trace(() => `Element[${i}] started rendering`); 111 | element.calculatedSize = size(element.from!, element.to!); 112 | 113 | Logger.trace(() => `Element[${i}] geometry = ${element.calculatedSize!.join(',')}`); 114 | 115 | const geometry = new THREE.BoxGeometry(...element.calculatedSize, 1, 1, 1); 116 | const cube = new THREE.Mesh(geometry, await constructBlockMaterial(minecraft, block, element)); 117 | 118 | cube.position.set(0, 0, 0); 119 | cube.position.add(new THREE.Vector3(...element.from!)); 120 | cube.position.add(new THREE.Vector3(...element.to!)); 121 | cube.position.multiplyScalar(0.5); 122 | cube.position.add(new THREE.Vector3(-8, -8, -8)); 123 | 124 | Logger.trace(() => `Element[${i}] position set to ${cube.position.toArray().join(',')}`); 125 | 126 | if (element.rotation) { 127 | const origin = mul(element.rotation.origin!, -0.0625); 128 | cube.applyMatrix4(new THREE.Matrix4().makeTranslation(...invert(origin))); 129 | 130 | if (element.rotation.axis == 'y') { 131 | cube.applyMatrix4(new THREE.Matrix4().makeRotationY(THREE.MathUtils.DEG2RAD * element.rotation.angle!)); 132 | } else if (element.rotation.axis == 'x') { 133 | cube.applyMatrix4(new THREE.Matrix4().makeRotationX(THREE.MathUtils.DEG2RAD * element.rotation.angle!)); 134 | } 135 | 136 | cube.applyMatrix4(new THREE.Matrix4().makeTranslation(...origin)); 137 | cube.updateMatrix(); 138 | 139 | Logger.trace(() => `Element[${i}] rotation applied`); 140 | } 141 | 142 | cube.renderOrder = ++i; 143 | 144 | scene.add(cube); 145 | clean.push(cube); 146 | } 147 | 148 | const rotation = new THREE.Vector3(...gui.rotation).add(new THREE.Vector3(195, -90, -45)); 149 | camera.position.set(...rotation.toArray().map(x => Math.sin(x * THREE.MathUtils.DEG2RAD) * 16) as [number, number, number]); 150 | camera.lookAt(0, 0, 0); 151 | camera.position.add(new THREE.Vector3(...gui.translation)); 152 | camera.updateMatrix(); 153 | camera.updateProjectionMatrix(); 154 | 155 | Logger.trace(() => `Camera position set ${camera.position.toArray().join(',')}`); 156 | 157 | renderer.render(scene, camera); 158 | 159 | const buffer = canvas.toBuffer('image/png'); 160 | buffers.push(buffer); 161 | 162 | Logger.trace(() => `Image rendered, buffer size = ${buffer.byteLength} bytes`); 163 | 164 | for (const old of clean) { 165 | scene.remove(old); 166 | } 167 | 168 | Logger.trace(() => `Scene cleared`); 169 | 170 | Logger.trace(() => `Frame[${block.animationCurrentTick}] completed`); 171 | } 172 | while (options.animation && (block.animationMaxTicks ?? 1) > ++block.animationCurrentTick); 173 | 174 | resultBlock.buffer = buffers.length == 1 ? buffers[0] : makeAnimatedPNG(buffers, index => ({ numerator: 1, denominator: 10 })); 175 | 176 | return resultBlock; 177 | } 178 | 179 | 180 | async function constructTextureMaterial(minecraft: Minecraft, block: BlockModel, path: string, face: Face, element: Element, direction: string) { 181 | const cache = minecraft.getRenderer().textureCache; 182 | const animatedCache = minecraft.getRenderer().animatedCache; 183 | const image = cache[path] ? cache[path] : (cache[path] = await loadImage(await minecraft.getTextureFile(path))); 184 | 185 | const animationMeta = animatedCache[path] ? animatedCache[path] : (animatedCache[path] = await minecraft.getTextureMetadata(path)); 186 | 187 | const width = image.width; 188 | let height = animationMeta ? width : image.height; 189 | let frame = 0; 190 | 191 | if (animationMeta) { // TODO: Consider custom frame times 192 | Logger.trace(() => `Face[${direction}] is animated!`); 193 | 194 | const frameCount = image.height / width; 195 | 196 | if (block.animationCurrentTick == 0) { 197 | block.animationMaxTicks = Math.max(block.animationMaxTicks || 1, frameCount * (animationMeta.frametime || 1)); 198 | } else { 199 | frame = Math.floor(block.animationCurrentTick! / (animationMeta.frametime || 1)) % frameCount; 200 | } 201 | } 202 | 203 | const canvas = rawCanvas.createCanvas(width, height); 204 | const ctx = canvas.getContext('2d'); 205 | 206 | 207 | ctx.imageSmoothingEnabled = false; 208 | 209 | if (face.rotation) { 210 | ctx.translate(width / 2, height / 2); 211 | ctx.rotate(face.rotation * THREE.MathUtils.DEG2RAD); 212 | ctx.translate(-width / 2, -height / 2); 213 | 214 | Logger.trace(() => `Face[${direction}] rotation applied`); 215 | } 216 | 217 | const uv = face.uv ?? [0, 0, width, height]; 218 | 219 | ctx.drawImage(image, uv[0], uv[1] + frame * height, uv[2] - uv[0], uv[3] - uv[1], 0, 0, width, height); 220 | 221 | Logger.trace(() => `Face[${direction}] uv applied`); 222 | 223 | const texture = new THREE.Texture(canvas as any); 224 | texture.magFilter = THREE.NearestFilter; 225 | texture.minFilter = THREE.NearestFilter; 226 | texture.needsUpdate = true; 227 | 228 | 229 | Logger.trace(() => `Face[${direction}] texture is ready`); 230 | 231 | return new THREE.MeshStandardMaterial({ 232 | map: texture, 233 | color: 0xffffff, 234 | transparent: true, 235 | roughness: 1, 236 | metalness: 0, 237 | emissive: 1, 238 | alphaTest: 0.1 239 | }); 240 | } 241 | 242 | async function constructBlockMaterial(minecraft: Minecraft, block: BlockModel, element: Element): Promise { 243 | if (!element?.faces) { 244 | Logger.debug(() => `Element faces are missing, will be skipped`); 245 | return [] 246 | }; 247 | 248 | return await Promise.all(MATERIAL_FACE_ORDER.map(direction => decodeFace(direction, element?.faces?.[direction], block, element, minecraft))); 249 | } 250 | 251 | 252 | async function decodeFace(direction: string, face: Face | null | undefined, block: BlockModel, element: Element, minecraft: Minecraft): Promise { 253 | if (!face) { 254 | Logger.trace(() => `Face[${direction}] doesn't exist`); 255 | return null; 256 | } 257 | 258 | const decodedTexture = decodeTexture(face.texture, block); 259 | 260 | if (!decodedTexture) { 261 | Logger.debug(() => `Face[${direction}] exist but texture couldn't be decoded! texture=${face.texture}`); 262 | return null; 263 | } 264 | 265 | return await constructTextureMaterial(minecraft, block, decodedTexture!, face!, element, direction); 266 | } 267 | 268 | 269 | function decodeTexture(texture: string, block: BlockModel): string | null { 270 | texture = texture ?? ''; 271 | if (!texture) return null; 272 | if (!texture.startsWith('#')) { 273 | return texture; 274 | } 275 | 276 | const correctedTextureName = (block.textures!)[texture.substring(1) as BlockSides]!; 277 | 278 | Logger.trace(() => `Texture "${texture}" decoded to "${correctedTextureName}"`); 279 | 280 | return decodeTexture(correctedTextureName, block); 281 | } --------------------------------------------------------------------------------