forked from mirror/ObjToSchematic
Support for non-uniform textured blocks
This commit is contained in:
parent
0b47fc04e7
commit
f77f60e729
14084
resources/blocks.json
14084
resources/blocks.json
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 414 KiB |
@ -129,6 +129,7 @@ export class AppContext {
|
||||
this._renderer.compile();
|
||||
} catch (err: any) {
|
||||
this._showToast(err.message, ToastColour.RED);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -5,10 +5,26 @@ import { UV, RGB } from "./util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
|
||||
export interface TextureInfo {
|
||||
name: string
|
||||
texcoord: UV
|
||||
}
|
||||
|
||||
export interface FaceInfo {
|
||||
[face: string]: TextureInfo,
|
||||
up: TextureInfo,
|
||||
down: TextureInfo,
|
||||
north: TextureInfo,
|
||||
south: TextureInfo,
|
||||
east: TextureInfo,
|
||||
west: TextureInfo
|
||||
}
|
||||
|
||||
export interface BlockInfo {
|
||||
name: string;
|
||||
colour: RGB;
|
||||
texcoord: UV
|
||||
faces: FaceInfo
|
||||
}
|
||||
|
||||
// https://minecraft.fandom.com/wiki/Java_Edition_data_values/Pre-flattening/Block_IDs
|
||||
|
@ -11,6 +11,7 @@ import { RGB, UV, rgbToArray } from "./util";
|
||||
import { VoxelManager } from "./voxel_manager";
|
||||
import { Triangle } from "./triangle";
|
||||
import { Mesh, FillMaterial, TextureMaterial, MaterialType } from "./mesh";
|
||||
import { FaceInfo } from "./block_atlas";
|
||||
|
||||
|
||||
export class Renderer {
|
||||
@ -50,7 +51,7 @@ export class Renderer {
|
||||
this._debug = false;
|
||||
this._compiled = false;
|
||||
|
||||
|
||||
console.log(twgl.primitives.createCubeVertices(1.0));
|
||||
|
||||
|
||||
//this._blockTexture = twgl.createTexture(this._gl, { src: "resources/blocks/stone.png", mag: this._gl.NEAREST });
|
||||
@ -79,7 +80,7 @@ export class Renderer {
|
||||
this._registerData(data);
|
||||
}
|
||||
|
||||
private _registerVoxel(centre: Vector3, voxelManager: VoxelManager, blockTexcoord: UV) {
|
||||
private _registerVoxel(centre: Vector3, voxelManager: VoxelManager, blockTexcoord: FaceInfo) {
|
||||
let occlusions = new Array<Array<number>>(6);
|
||||
// For each face
|
||||
for (let f = 0; f < 6; ++f) {
|
||||
@ -109,9 +110,14 @@ export class Renderer {
|
||||
data.occlusion[j * 16 + k] = occlusions[j][k % 4];
|
||||
}
|
||||
}
|
||||
const l = data.position.length / 3;
|
||||
for (let i = 0; i < l; ++i) {
|
||||
data.blockTexcoord.push(blockTexcoord.u, blockTexcoord.v);
|
||||
|
||||
// Assign the textures to each face
|
||||
const faceOrder = ["north", "south", "up", "down", "east", "west"];
|
||||
for (const face of faceOrder) {
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const texcoord = blockTexcoord[face].texcoord;
|
||||
data.blockTexcoord.push(texcoord.u, texcoord.v);
|
||||
}
|
||||
}
|
||||
|
||||
this._registerVoxels.add(data);
|
||||
|
@ -2,7 +2,7 @@ import { CubeAABB } from "./aabb";
|
||||
import { Vector3 } from "./vector.js";
|
||||
import { HashMap } from "./hash_map";
|
||||
import { Texture } from "./texture";
|
||||
import { BlockAtlas, BlockInfo } from "./block_atlas";
|
||||
import { BlockAtlas, BlockInfo, TextureInfo, FaceInfo } from "./block_atlas";
|
||||
import { UV, RGB } from "./util";
|
||||
import { Triangle } from "./triangle";
|
||||
import { Mesh, MaterialType } from "./mesh";
|
||||
@ -14,7 +14,6 @@ interface Block {
|
||||
block?: string
|
||||
}
|
||||
|
||||
|
||||
interface TriangleCubeAABBs {
|
||||
triangle: Triangle;
|
||||
AABBs: Array<CubeAABB>;
|
||||
@ -23,7 +22,7 @@ interface TriangleCubeAABBs {
|
||||
export class VoxelManager {
|
||||
|
||||
public voxels: Array<Block>;
|
||||
public voxelTexcoords: Array<UV>;
|
||||
public voxelTexcoords: Array<FaceInfo>;
|
||||
public triangleAABBs: Array<TriangleCubeAABBs>;
|
||||
public _voxelSize: number;
|
||||
|
||||
@ -120,7 +119,7 @@ export class VoxelManager {
|
||||
averageColour.b /= n;
|
||||
const block = this.blockAtlas.getBlock(averageColour);
|
||||
this.voxels[i].block = block.name;
|
||||
this.voxelTexcoords.push(block.texcoord);
|
||||
this.voxelTexcoords.push(block.faces);
|
||||
|
||||
if (!this.blockPalette.includes(block.name)) {
|
||||
this.blockPalette.push(block.name);
|
||||
|
@ -1,174 +1,188 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PNGWithMetadata } from "pngjs";
|
||||
import images from "images";
|
||||
import { RGB, UV } from "../src/util";
|
||||
import { log, LogStyle } from "./logging";
|
||||
import { PNG } from "pngjs";
|
||||
import { isDirSetup, assert, getAverageColour } from "./misc";
|
||||
import chalk from "chalk";
|
||||
|
||||
import { logStatus, logSuccess, logInfo, isDirSetup, assert, doesTextureFileExists, getTextureData, logWarning, getAbsoluteTexturePath, getAverageColour, getTextureName } from "./misc";
|
||||
|
||||
|
||||
|
||||
logStatus("Checking Minecraft assets are provided...");
|
||||
// Check /blocks and /models is setup correctly
|
||||
log(LogStyle.None, "Checking Minecraft assets are provided...");
|
||||
const blocksDirSetup = isDirSetup("./blocks", "assets/minecraft/textures/block");
|
||||
const modelsDirSetup = isDirSetup("./models", "assets/minecraft/models/block");
|
||||
assert(blocksDirSetup && modelsDirSetup, "Folders not setup correctly");
|
||||
logSuccess("Folders setup correctly");
|
||||
log(LogStyle.Success, "Folders setup correctly\n")
|
||||
|
||||
|
||||
|
||||
logStatus("Loading ignore list...")
|
||||
// Load the ignore list
|
||||
log(LogStyle.None, "Loading ignore list...")
|
||||
let ignoreList: Array<string> = [];
|
||||
const ignoreListPath = path.join(__dirname, "./ignore-list.txt");
|
||||
const defaultIgnoreListPath = path.join(__dirname, "./default-ignore-list.txt");
|
||||
if (fs.existsSync(ignoreListPath)) {
|
||||
logSuccess("Found custom ignore list");
|
||||
log(LogStyle.Success, "Found custom ignore list");
|
||||
ignoreList = fs.readFileSync(ignoreListPath, "utf-8").replace(/\r/g, "").split("\n");
|
||||
} else if (fs.existsSync(defaultIgnoreListPath)){
|
||||
logSuccess("Found default ignore list");
|
||||
log(LogStyle.Success, "Found default ignore list");
|
||||
ignoreList = fs.readFileSync(defaultIgnoreListPath, "utf-8").replace(/\r/g, "").split("\n");
|
||||
} else {
|
||||
logWarning("No ignore list found, looked for ignore-list.txt and default-ignore-list.txt");
|
||||
log(LogStyle.Warning, "No ignore list found, looked for ignore-list.txt and default-ignore-list.txt");
|
||||
}
|
||||
logInfo(`${ignoreList.length} blocks found in ignore list`);
|
||||
log(LogStyle.Info, `${ignoreList.length} blocks found in ignore list\n`);
|
||||
|
||||
|
||||
|
||||
interface BlockData {
|
||||
filePath: string,
|
||||
modelData: any
|
||||
}
|
||||
|
||||
interface BlockPNGData {
|
||||
pngData: {[texture: string]: PNGWithMetadata}
|
||||
}
|
||||
|
||||
logStatus("Loading block models...")
|
||||
let candidateAtlasBlocks: Array<BlockData> = [];
|
||||
let ignoredCount = 0;
|
||||
//
|
||||
log(LogStyle.None, "Loading block models...")
|
||||
|
||||
enum parentModel {
|
||||
Cube = "minecraft:block/cube",
|
||||
CubeAll = "minecraft:block/cube_all",
|
||||
CubeColumn = "minecraft:block/cube_column",
|
||||
Cube = "minecraft:block/cube"
|
||||
CubeColumnHorizontal = "minecraft:block/cube_column_horizontal"
|
||||
}
|
||||
|
||||
//const supportedModels = [parentModel.CubeAll, parentModel.CubeColumn, parentModel.Cube];
|
||||
const supportedModels = [parentModel.CubeAll];
|
||||
interface Model {
|
||||
name: string,
|
||||
colour?: RGB,
|
||||
faces: {
|
||||
[face: string]: Texture
|
||||
}
|
||||
}
|
||||
|
||||
interface Texture {
|
||||
name: string,
|
||||
texcoord?: UV,
|
||||
colour?: RGB
|
||||
}
|
||||
|
||||
const faces = ["north", "south", "up", "down", "east", "west"];
|
||||
let allModels: Array<Model> = [];
|
||||
let usedTextures: Set<string> = new Set();
|
||||
fs.readdirSync(path.join(__dirname, "./models")).forEach(filename => {
|
||||
if (path.extname(filename) !== ".json") {
|
||||
return;
|
||||
};
|
||||
|
||||
const filePath = path.join(__dirname, "./models", filename);
|
||||
const fileData = fs.readFileSync(filePath, "utf8");
|
||||
const modelData = JSON.parse(fileData);
|
||||
if (supportedModels.includes(modelData.parent) && !ignoreList.includes(filename)) {
|
||||
candidateAtlasBlocks.push({filePath: filePath, modelData: modelData});
|
||||
} else {
|
||||
++ignoredCount;
|
||||
const parsedPath = path.parse(filePath);
|
||||
const modelName = parsedPath.name;
|
||||
|
||||
if (ignoreList.includes(filename)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let faceData: {[face: string]: Texture} = {};
|
||||
switch (modelData.parent) {
|
||||
case parentModel.CubeAll:
|
||||
faceData = {
|
||||
up: { name: modelData.textures.all },
|
||||
down: { name: modelData.textures.all },
|
||||
north: { name: modelData.textures.all },
|
||||
south: { name: modelData.textures.all },
|
||||
east: { name: modelData.textures.all },
|
||||
west: { name: modelData.textures.all }
|
||||
}
|
||||
break;
|
||||
case parentModel.CubeColumn:
|
||||
faceData = {
|
||||
up: { name: modelData.textures.end },
|
||||
down: { name: modelData.textures.end },
|
||||
north: { name: modelData.textures.side },
|
||||
south: { name: modelData.textures.side },
|
||||
east: { name: modelData.textures.side },
|
||||
west: { name: modelData.textures.side }
|
||||
}
|
||||
break;
|
||||
case parentModel.Cube:
|
||||
faceData = {
|
||||
up: { name: modelData.textures.up },
|
||||
down: { name: modelData.textures.down },
|
||||
north: { name: modelData.textures.north },
|
||||
south: { name: modelData.textures.south },
|
||||
east: { name: modelData.textures.east },
|
||||
west: { name: modelData.textures.west }
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
for (const face of faces) {
|
||||
usedTextures.add(faceData[face].name);
|
||||
}
|
||||
|
||||
allModels.push({
|
||||
name: modelName,
|
||||
faces: faceData
|
||||
});
|
||||
});
|
||||
assert(candidateAtlasBlocks.length > 0, "No blocks supplied are supported");
|
||||
logSuccess(`Collected ${candidateAtlasBlocks.length} blocks, ignored ${ignoredCount} unsupported blocks`);
|
||||
log(LogStyle.Success, `${allModels.length} blocks loaded\n`);
|
||||
|
||||
|
||||
|
||||
logStatus("Loading block textures...");
|
||||
let textureAbsolutePaths = [];
|
||||
let atlasBlocks: Array<BlockData & BlockPNGData> = [];
|
||||
for (const blockData of candidateAtlasBlocks) {
|
||||
|
||||
const atlasBlock: (BlockData & BlockPNGData) = {
|
||||
filePath: blockData.filePath,
|
||||
modelData: blockData.modelData,
|
||||
pngData: {}
|
||||
}
|
||||
|
||||
let texturesValid = true;
|
||||
for (const key in blockData.modelData.textures) {
|
||||
const texturePath = blockData.modelData.textures[key];
|
||||
if (!doesTextureFileExists(texturePath)) {
|
||||
// TODO: Use first frame
|
||||
logWarning(`Texture for ${texturePath} does not exist in ./models, ignoring...`);
|
||||
texturesValid = false;
|
||||
break;
|
||||
}
|
||||
const textureData: PNGWithMetadata = getTextureData(texturePath);
|
||||
if (textureData.width !== textureData.height) {
|
||||
logWarning(`${texturePath} is an animated/non-square texture which is not yet supported, ignoring...`);
|
||||
texturesValid = false;
|
||||
break;
|
||||
}
|
||||
if (textureData.width !== 16 || textureData.height !== 16) {
|
||||
logWarning(`${texturePath} is not a 16x16 texture and is not yet supported, ignoring...`);
|
||||
texturesValid = false;
|
||||
break;
|
||||
}
|
||||
if (textureData.width !== 16 || textureData.height !== 16) {
|
||||
logWarning(`${texturePath} is not a 16x16 texture and is not yet supported, ignoring...`);
|
||||
texturesValid = false;
|
||||
break;
|
||||
}
|
||||
textureAbsolutePaths.push(getAbsoluteTexturePath(texturePath));
|
||||
atlasBlock.pngData[key] = textureData;
|
||||
}
|
||||
|
||||
if (texturesValid) {
|
||||
atlasBlocks.push(atlasBlock);
|
||||
}
|
||||
}
|
||||
assert(atlasBlocks.length > 0, "No blocks supplied have supported textures");
|
||||
logSuccess(`Textures for ${atlasBlocks.length} block loaded`);
|
||||
|
||||
|
||||
|
||||
logStatus("Stitching textures together...");
|
||||
const atlasSize = Math.ceil(Math.sqrt(textureAbsolutePaths.length));
|
||||
const atlasSize = Math.ceil(Math.sqrt(usedTextures.size));
|
||||
const atlasWidth = atlasSize * 16;
|
||||
let atlasElements = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (let i = 0; i < textureAbsolutePaths.length; ++i) {
|
||||
atlasElements.push({
|
||||
src: textureAbsolutePaths[i],
|
||||
offsetX: 3 * 16 * x++,
|
||||
offsetY: 3 * 16 * y
|
||||
})
|
||||
if (x >= atlasSize) {
|
||||
++y;
|
||||
x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let offsetX = 0, offsetY = 0;
|
||||
const outputImage = images(atlasWidth * 3, atlasWidth * 3);
|
||||
|
||||
let textureDetails: {[textureName: string]: {texcoord: UV, colour: RGB}} = {};
|
||||
|
||||
|
||||
let outputBlocks = [];
|
||||
let outputImage = images(atlasWidth * 3, atlasWidth * 3);
|
||||
for (const element of atlasElements) {
|
||||
// Tile each texture in a 3x3 grid to avoid UV bleeding
|
||||
// TODO: Just repeat the outer layer of pixels instead of 3x3
|
||||
const texture = images(element.src);
|
||||
log(LogStyle.None, "Building blocks.png...");
|
||||
usedTextures.forEach(textureName => {
|
||||
const shortName = textureName.split("/")[1]; // Eww
|
||||
const absolutePath = path.join(__dirname, "./blocks", shortName + ".png");
|
||||
const fileData = fs.readFileSync(absolutePath);
|
||||
const pngData = PNG.sync.read(fileData);
|
||||
const image = images(absolutePath);
|
||||
|
||||
for (let x = 0; x < 3; ++x) {
|
||||
for (let y = 0; y < 3; ++y) {
|
||||
outputImage.draw(texture, element.offsetX + 16 * x, element.offsetY + 16 * y);
|
||||
outputImage.draw(image, 16 * (3 * offsetX + x), 16 * (3 * offsetY + y));
|
||||
}
|
||||
}
|
||||
outputBlocks.push({
|
||||
colour: getAverageColour(element.src),
|
||||
|
||||
textureDetails[textureName] = {
|
||||
texcoord: {
|
||||
u: (element.offsetX + 16) / (atlasWidth * 3),
|
||||
v: (element.offsetY + 16) / (atlasWidth * 3),
|
||||
u: 16 * (3 * offsetX + 1) / (atlasWidth * 3),
|
||||
v: 16 * (3 * offsetY + 1) / (atlasWidth * 3)
|
||||
},
|
||||
name: path.parse(element.src).name
|
||||
});
|
||||
colour: getAverageColour(pngData)
|
||||
}
|
||||
|
||||
++offsetX;
|
||||
if (offsetX >= atlasSize) {
|
||||
++offsetY;
|
||||
offsetX = 0;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Build up the output JSON
|
||||
log(LogStyle.None, "Building blocks.json...\n");
|
||||
for (const model of allModels) {
|
||||
let blockColour = {r: 0, g: 0, b: 0};
|
||||
for (const face of faces) {
|
||||
const faceTexture = textureDetails[model.faces[face].name];
|
||||
const faceColour = faceTexture.colour;
|
||||
blockColour.r += faceColour.r;
|
||||
blockColour.g += faceColour.g;
|
||||
blockColour.b += faceColour.b;
|
||||
model.faces[face].texcoord = faceTexture.texcoord;
|
||||
}
|
||||
blockColour.r /= 6;
|
||||
blockColour.g /= 6;
|
||||
blockColour.b /= 6;
|
||||
model.colour = blockColour;
|
||||
}
|
||||
logSuccess("Atlas texture created");
|
||||
|
||||
|
||||
|
||||
logStatus("Exporting...");
|
||||
log(LogStyle.None, "Exporting...");
|
||||
outputImage.save(path.join(__dirname, "../resources/blocks.png"));
|
||||
logSuccess("blocks.png exported to /resources");
|
||||
let outputJSON = { atlasSize: atlasSize, blocks: outputBlocks };
|
||||
fs.writeFileSync(path.join(__dirname, "../resources/blocks.json"), JSON.stringify(outputJSON));
|
||||
logSuccess("blocks.json exported to /resources");
|
||||
log(LogStyle.Success, "blocks.png exported to /resources");
|
||||
let outputJSON = { atlasSize: atlasSize, blocks: allModels };
|
||||
fs.writeFileSync(path.join(__dirname, "../resources/blocks.json"), JSON.stringify(outputJSON, null, 4));
|
||||
log(LogStyle.Success, "blocks.json exported to /resources\n");
|
||||
|
||||
console.log(chalk.cyanBright(chalk.inverse("DONE") + " Now run " + chalk.inverse("npm start") + " and the new blocks will be used"));
|
@ -16,6 +16,7 @@ green_stained_glass.json
|
||||
light_gray_stained_glass.json
|
||||
magenta_stained_glass.json
|
||||
pink_stained_glass.json
|
||||
blue_stained_glass.json
|
||||
purple_stained_glass.json
|
||||
red_stained_glass.json
|
||||
white_stained_glass.json
|
||||
|
24
tools/logging.ts
Normal file
24
tools/logging.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export enum LogStyle {
|
||||
None = "None",
|
||||
Info = "Info",
|
||||
Warning = "Warning",
|
||||
Failure = "Failure",
|
||||
Success = "Success"
|
||||
}
|
||||
|
||||
const LogStyleDetails: {[style: string]: {style: chalk.Chalk, prefix: string}} = {};
|
||||
LogStyleDetails[LogStyle.Info] = {style: chalk.white, prefix: chalk.white.inverse("INFO")};
|
||||
LogStyleDetails[LogStyle.Warning] = {style: chalk.yellow, prefix: chalk.yellow.inverse("WARN")};
|
||||
LogStyleDetails[LogStyle.Failure] = {style: chalk.red, prefix: chalk.red.inverse("UHOH")};
|
||||
LogStyleDetails[LogStyle.Success] = {style: chalk.green, prefix: chalk.green.inverse(" OK ")};
|
||||
|
||||
export function log(style: LogStyle, message: string) {
|
||||
if (style === LogStyle.None) {
|
||||
console.log(chalk.whiteBright(message));
|
||||
} else {
|
||||
const details: {style: chalk.Chalk, prefix: string} = LogStyleDetails[style];
|
||||
console.log(details.prefix + " " + details.style(message));
|
||||
}
|
||||
}
|
@ -1,15 +1,9 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import chalk from "chalk";
|
||||
import { PNG, PNGWithMetadata } from "pngjs";
|
||||
import { log, LogStyle } from "./logging";
|
||||
|
||||
|
||||
export const logWarning = (message: string) => { console.log(chalk.yellow.inverse("WARN") + " " + chalk.yellow(message)); }
|
||||
export const logFailure = (message: string) => { console.log(chalk.red.inverse("UHOH") + " " + chalk.red(message)); }
|
||||
export const logStatus = (message: string) => { console.log("\n" + chalk.bold(message)); }
|
||||
export const logInfo = (message: string) => { console.log(chalk.white.inverse("INFO") + " " + chalk.white(message)); }
|
||||
export const logSuccess = (message: string) => { console.log(chalk.green.inverse(" OK ") + " " + chalk.green(message)); }
|
||||
export const assert = (condition: boolean, onFailMessage: string) => { if (!condition) { logFailure(onFailMessage); process.exit(0); } }
|
||||
export const assert = (condition: boolean, onFailMessage: string) => { if (!condition) { log(LogStyle.Failure, onFailMessage); process.exit(0); } }
|
||||
|
||||
export function isDirSetup(relativePath: string, jarAssetDir: string) {
|
||||
const dir = path.join(__dirname, relativePath)
|
||||
@ -20,35 +14,11 @@ export function isDirSetup(relativePath: string, jarAssetDir: string) {
|
||||
} else {
|
||||
fs.mkdirSync(dir);
|
||||
}
|
||||
logWarning(`Copy the contents of .minecraft/versions/<version>/<version>.jar/${jarAssetDir} from a Minecraft game files into ${relativePath}`);
|
||||
log(LogStyle.Warning, `Copy the contents of .minecraft/versions/<version>/<version>.jar/${jarAssetDir} from a Minecraft game files into ${relativePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTextureName(textureName: string) {
|
||||
return getShortTextureName(textureName).split(".")[0];
|
||||
}
|
||||
|
||||
export function getShortTextureName(textureName: string) {
|
||||
return textureName.split("/")[1]; //TODO: Eww
|
||||
}
|
||||
|
||||
export function getAbsoluteTexturePath(textureName: string) {
|
||||
return path.join(__dirname, "./blocks", getShortTextureName(textureName) + ".png");
|
||||
}
|
||||
|
||||
export function doesTextureFileExists(textureName: string) {
|
||||
return fs.existsSync(getAbsoluteTexturePath(textureName));
|
||||
}
|
||||
|
||||
export function getTextureData(textureName: string): PNGWithMetadata {
|
||||
const fileData = fs.readFileSync(getAbsoluteTexturePath(textureName));
|
||||
return PNG.sync.read(fileData);
|
||||
}
|
||||
|
||||
export function getAverageColour(path: string) {
|
||||
const data = fs.readFileSync(path);
|
||||
const image = PNG.sync.read(data);
|
||||
|
||||
export function getAverageColour(image: PNG) {
|
||||
let r = 0, g = 0, b = 0;
|
||||
for (let x = 0; x < image.width; ++x) {
|
||||
for (let y = 0; y < image.height; ++y) {
|
||||
|
Loading…
Reference in New Issue
Block a user