Skip to content

Commit

Permalink
Improve 3D map height shading
Browse files Browse the repository at this point in the history
Fixes #15.
  • Loading branch information
gd-codes committed Mar 12, 2024
1 parent d297962 commit d2f7242
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 14 deletions.
51 changes: 44 additions & 7 deletions scripts/functionWriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Minecraft Pixel Art Maker
* @param {Array<Array<Number>>} shademap - Pixel-wise light/dark variation data; each value in this
* is 255 if the pixel is the base colour of the material, 254 if darker variant, 253 if brighter variant
* @returns 2D Array of pixel-wise height coordinates required to achieve the given light/dark variation
* with minecraft blocks.
* with minecraft blocks (result); and the total number of discontinuities caused by maxY limit (cuts).
*/
function findYMap(imgdata, maxY, shademap) {
var Ymap = [], x, z, column, type, lastY, min;
var Ymap = [], x, z, column, type, lastY, min, cuts=0;
for (x=0; x<imgdata.width; x++) {
column = [0]; min=0;
for (z=1; z<imgdata.height; z++) {
Expand All @@ -37,10 +37,47 @@ function findYMap(imgdata, maxY, shademap) {
}
}
//Bring everything within 0 and height limit
column = column.map(a => (a - min) % maxY);
Ymap.push(column);
let clamp = clampSequence(column, maxY);
cuts += clamp.cuts;
Ymap.push(clamp.result);
}
return Ymap;
return {result:Ymap, cuts:cuts};
}

/**
* Given an unrestricted sequence of integers, restrict it to the range 0..lim by "cutting"
* at the fewest number of places possible and shifting the sections (i.e. preserve deltas
* modified[i+1]-modified[i] == original[i+1]-original[i] as far as possible).
* @param {Array<Number>} arr - Input integer sequence
* @param {Number} lim - Max permissible value
* @returns Bounded sequence same size as arr (result); Number of cuts made (cuts)
*/
function clampSequence(arr, lim) {
let mn = Number.MAX_SAFE_INTEGER;
let mx = Number.MIN_SAFE_INTEGER;
const res = [];
const buffer = [];
let cuts = 0;
for (let i = 0; i < arr.length; i++) {
const lmn = mn; // Local (buffer) min
const lmx = mx; // Local (buffer) max
mn = Math.min(arr[i], mn);
mx = Math.max(arr[i], mx);
if (mx - mn > lim) {
for (let j = 0; j < buffer.length; j++) {
res.push(buffer[j] - lmn);
}
buffer.length = 0;
cuts += 1;
mn = arr[i];
mx = arr[i];
}
buffer.push(arr[i]);
}
for (let j = 0; j < buffer.length; j++) {
res.push(buffer[j] - mn);
}
return {result:res, cuts:cuts};
}

/**
Expand Down Expand Up @@ -68,7 +105,7 @@ function getSurvivalGuideTableData(uid) {
}
var ymax = ($("#3dSwitch_"+uid+":checked").length > 0)? $("#heightInput_"+uid).val() : 0;
if (ymax > 1) {
yMap = findYMap(image, ymax, PictureData[uid]['shadeMap']); // Defined in `functionwriter.js`
yMap = findYMap(image, ymax, PictureData[uid]['shadeMap']).result;
}

// Begin creating the data for each zone
Expand Down Expand Up @@ -133,7 +170,7 @@ function writeCommands(name, imobj, palettesize, height, keep, linkpos, strucs,
}
if (height > 1) {
ymax = height;
yMap = findYMap(imobj, ymax, shademap);
yMap = findYMap(imobj, ymax, shademap).result;
}
for (i=0; i<zone_origins.length; i++) {
var fun="", x, y, z, xloop, zloop, pix, code, replMode;
Expand Down
74 changes: 69 additions & 5 deletions scripts/imageProcessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ function analyseImage(uid, image, area, palette, is3D, dither) {
var p = [];
for (var cn of palette.split(" ")) {
if (Colours.get(cn) !== undefined) {
var clr = Colours.get(cn); p.push(clr.rgb);
var clr = Colours.get(cn);
// This order is important!! See correctShadeHeightEffect()
p.push(clr.rgb);
if (is3D) {
p.push(darkPixel(clr.rgb)); p.push(lightPixel(clr.rgb));
p.push(darkPixel(clr.rgb));
p.push(lightPixel(clr.rgb));
}
}
}
// Resize the image to fit number of pixels in minceraft maps
// Resize the image to fit number of pixels in minceraft maps, using browser canvas
ctx.drawImage(image, 0, 0, image.width, image.height);
ctx.clearRect(0,0,canv.width,canv.height);
canv.height = h; canv.width = w;
Expand All @@ -53,7 +56,16 @@ function analyseImage(uid, image, area, palette, is3D, dither) {
for (let i=0; i<w; i++)
PictureData[uid]['shadeMap'][i] = new Array(h);

// Perform quantization/dithering pass, obtaining the unrestricted/natural heights
var finalImgData = convertPalette(p, imgData, dither, PictureData[uid]['shadeMap']);
// Edit the image to reflect errors that will be obtained due to height limit
var heightTooLow = 0;
if (is3D) {
let ymax = $("#heightInput_"+uid).val();
let ymap = findYMap(finalImgData, ymax, PictureData[uid]['shadeMap']);
heightTooLow = ymap.cuts;
correctShadeHeightEffect(finalImgData, p, ymap.result);
}
ctx.putImageData(finalImgData, 0, 0);
var converted_image = canv.toDataURL("image/png");
ctx.clearRect(0, 0, w, h);
Expand All @@ -67,6 +79,12 @@ function analyseImage(uid, image, area, palette, is3D, dither) {
.width(image.width);
$('#downloadImageButton').attr('href', image.src);
$('#downloadImageButton').attr('download', ($('#fnNameInput_'+uid).val() + '-original.png'));
if (PictureData[uid].originalWasResized === true) {
$('#imgModalResizeWarning').removeClass('d-none');
} else {
$('#imgModalResizeWarning').addClass('d-none');
}
$('#imgModalHeightWarning').addClass('d-none');
$("#imageDisplayModal").modal('show');
});
$("#viewResizedImgBtn_"+uid).click(function() {
Expand All @@ -75,6 +93,8 @@ function analyseImage(uid, image, area, palette, is3D, dither) {
.width(w*dispScale);
$('#downloadImageButton').attr('href', resized_image);
$('#downloadImageButton').attr('download', ($('#fnNameInput_'+uid).val() + '-resized.png'));
$('#imgModalResizeWarning').addClass('d-none');
$('#imgModalHeightWarning').addClass('d-none');
$("#imageDisplayModal").modal('show');
});
$("#viewFinalImgBtn_"+uid).click(function() {
Expand All @@ -83,6 +103,13 @@ function analyseImage(uid, image, area, palette, is3D, dither) {
.width(w*dispScale);
$('#downloadImageButton').attr('href', converted_image);
$('#downloadImageButton').attr('download', ($('#fnNameInput_'+uid).val() + '-converted.png'));
if (heightTooLow > 4*h) {
// Arbitrary threshold, average 4 cuts per column
$('#imgModalHeightWarning').removeClass('d-none');
} else {
$('#imgModalHeightWarning').addClass('d-none');
}
$('#imgModalResizeWarning').addClass('d-none');
$("#imageDisplayModal").modal('show');
})
}
Expand Down Expand Up @@ -167,6 +194,31 @@ function convertPalette(palette, pixels, dither, shademap) {
return pixels;
}

/**
* Edit pixel shades in the image to reflect in-game appearance according to the given height map.
* @param {ImageData} pixels - to be modified
* @param {Array<Array<Number>>} palette - sequence of colours present in the image -
* ordered normal, dark, light, normal, dark, light... for each material
* @param {Array<Array<Number>>} ymap - Actual clamped height values
*/
function correctShadeHeightEffect(pixels, palette, ymap) {
for (let x=0; x < pixels.width; x++) {
for (let z=0; z < pixels.height; z++) {
let p = getPixelAt(x, z, pixels);
let i = indexOfArray(p.slice(0,3), palette);
let tc = Math.floor(i / 3) * 3;
// For the top row, assume the block is higher (lighter) than the one N of it
// even though that is outside the map.
if (z === 0 || ymap[x][z] > ymap[x][z-1]) {
tc += 2;
} else if (ymap[x][z] < ymap[x][z-1]) {
tc += 1;
}
setPixelAt(x, z, pixels, [...(palette[tc]), 255]);
}
}
}

/**
* Create a PNG image to be used as a logo for the generated add-on in Minecraft,
* including the website's logo and a preview of contained artwork.
Expand Down Expand Up @@ -202,8 +254,20 @@ function makeLogo(images) {
* @returns RGBA value at the given coordinate
*/
function getPixelAt(x, z, dataobj) {
let i = 4*(dataobj.width*(z) + x);
return [dataobj.data[i], dataobj.data[i+1], dataobj.data[i+2], dataobj.data[i+3]];
let loc = 4*(dataobj.width*(z) + x);
return [dataobj.data[loc], dataobj.data[loc+1], dataobj.data[loc+2], dataobj.data[loc+3]];
}

/**
* Set the value of pixel (x,z) in a continuous 1D ravelled RGBARGBA... byte seq
* @param {Number} x - Pixel width coordinate
* @param {Number} z - Pixel height coordinate
* @param {ImageData} dataobj - Source Image Data array
* @param {Array<Number>} rgba - Colour to write
*/
function setPixelAt(x, z, dataobj, rgba) {
let loc = 4*(dataobj.width*(z) + x);
for (i=0; i<4; i++) dataobj.data[loc+i] = rgba[i];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion scripts/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const EJStemplates = {
<%=tabTitle%>
<% if (tabDirty) { %><small title="Has unsaved changes">&nbsp;*</small><% } %>
<% if (tabSavedDataSize > 0) { %>
<small title="Local storage: <%=((tabSavedDataSize / 1024 + 1) | 0)%> kb<% if (originalWasResized) { %> (stored resized image because original was too big; re-select the image to change area)<% } %>">
<small title="Local storage: <%=((tabSavedDataSize / 1024 + 1) | 0)%> kb<% if (originalWasResized) { %> (stored resized image because original was too big)<% } %>">
&nbsp;&nbsp;<%-SVGicons.save%><% if (originalWasResized) { %>&nbsp;&nbsp;<%-SVGicons.shrink%><% } %>
</small>
<% } %>`,
Expand Down
2 changes: 1 addition & 1 deletion sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Service Worker to enable the webpage to be used even offline, once installed
Cached site should require only ~ 3 MB space
*/

const CURRENT_CACHE_VERSION = 'mapart-cache-4.6.2';
const CURRENT_CACHE_VERSION = 'mapart-cache-4.6.3';

const CACHE_URLS_LOCAL = [
/* Important : `/` doesn't automatically fetch `/index.html` locally, explicitly cache it
Expand Down
8 changes: 8 additions & 0 deletions templates/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@
<div class="modal-body overflow-auto" id="imgModalContent">
<canvas id="imgModalCanvas" class="d-block mx-auto my-auto" width="0" height="0"></canvas>
<img id="displayImage" class="d-block mx-auto my-auto" alt="Image Preview">
<div class="mt-2 d-none alert alert-danger" id="imgModalResizeWarning">
The image was restored from local storage as the low-resolution resized version, since the original file size was too large.<br/>
Expanding the area / number of maps may result in low quality art. Re-upload the full size original in case you would like so.
</div>
<div class="mt-2 d-none alert alert-danger" id="imgModalHeightWarning">
A restrictive 3D height limit may cause more noise and/or stripy artifacts in the map art. Increasing the
height may improve the image quality.
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<a id="downloadImageButton" class="btn btn-outline-secondary">Download Image</a>
Expand Down

0 comments on commit d2f7242

Please sign in to comment.