/** Material mode added by Card Collection for artwork without foil. */
const CARD_ARTWORK_MATERIAL_KIND = 2;
/** Load and parse one OBJ resource during preview initialization. */
function loadModel(url)
{
const request = new XMLHttpRequest();
request.open("GET", url, false);
request.send(null);
if(request.status != 200 && request.status != 0)
{
throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
}
return parseOBJ(request.responseText);
}
/** Shade a card front with its artwork and no foil contribution. */
class ArtworkMaterial
{
/** Load the front artwork used by a plain card. */
constructor(gl, artwork_url)
{
this.gl = gl;
this.artwork = artwork_url instanceof Texture
? artwork_url
: new Texture(gl, artwork_url, {flip_y: true});
this.ready = this.artwork.ready;
}
/** Select plain artwork shading and bind the artwork texture. */
use(program)
{
this.gl.uniform1i(
program.uniform("u_material_kind"),
CARD_ARTWORK_MATERIAL_KIND);
this.artwork.use(program, "u_artwork", 0);
}
/** Release the artwork texture. */
dispose()
{
this.artwork.dispose();
}
}
/** Construct shared front and shell materials and partitioned models. */
function initScene(gl, program, obj_model, page_data)
{
const front_material = page_data.foil_url == null
? new ArtworkMaterial(gl, page_data.front_url)
: new PhysicalFoilMaterial(
gl,
page_data.front_url,
page_data.foil_url,
page_data.spectral_lut_url);
const shell_material = new SolidColorMaterial(gl, [0.5, 0.5, 0.5]);
const models = [];
let front_triangle_count = 0;
let shell_triangle_count = 0;
for(const geometry of obj_model.geometries)
{
const partition = partitionCardGeometry(geometry, CARD_MESH_LAYOUT);
const front_vertices = partition.front.data.position == null
? 0
: partition.front.data.position.length / 3;
const shell_vertices = partition.shell.data.position == null
? 0
: partition.shell.data.position.length / 3;
if(front_vertices > 0)
{
models.push(new Model(
gl, program, partition.front, front_material));
front_triangle_count += front_vertices / 3;
}
if(shell_vertices > 0)
{
models.push(new Model(
gl, program, partition.shell, shell_material));
shell_triangle_count += shell_vertices / 3;
}
}
if(front_triangle_count == 0 || shell_triangle_count == 0)
{
throw(new Error(
"Card model must contain both front and shell triangles."));
}
return {
scene: new Scene(models),
front_material,
};
}
/** Build the projection, view, and interactive card-model matrices. */
function calculateMatrices(canvas, camera_rotation)
{
const mat4 = glMatrix.mat4;
const aspect = canvas.clientWidth / canvas.clientHeight;
const projection_matrix = mat4.perspective(
mat4.create(), Math.PI / 8.0, aspect, 0.1, 100.0);
const view_matrix = mat4.translate(
mat4.create(), mat4.create(), [0.0, 0.0, -5.0]);
const model_matrix = mat4.create();
mat4.rotateX(model_matrix, model_matrix, Math.PI / 2.0);
mat4.rotateX(model_matrix, model_matrix, camera_rotation[0]);
mat4.rotateZ(model_matrix, model_matrix, camera_rotation[1]);
return {projection_matrix, view_matrix, model_matrix};
}
/** Normalize a pointer position around the center of the short canvas axis. */
function getNormalizedMousePos(move_event, canvas)
{
const rect = canvas.getBoundingClientRect();
const size = Math.min(rect.width, rect.height);
const mouse_x = (move_event.clientX - rect.left
- (rect.width - size) * 0.5) / size - 0.5;
const mouse_y = (rect.height - (move_event.clientY - rect.top)
- (rect.height - size) * 0.5) / size - 0.5;
return [mouse_x, mouse_y];
}
/** Smoothly bound an unbounded pointer coordinate to a half turn. */
function asymptoticBound(value)
{
return Math.atan(value) / Math.PI;
}
/** Resolve one canvas PNG export or reject a failed encoding. */
function canvasPng(canvas)
{
return new Promise(function encode(resolve, reject)
{
canvas.toBlob(function finish(blob)
{
if(blob == null)
{
reject(new Error("Failed to encode the card thumbnail."));
return;
}
resolve(blob);
}, "image/png");
});
}
/** Internal compile-time shader variants for renderer quality. */
const RENDER_QUALITY = Object.freeze({
LOW: Object.freeze({
LIGHT_SAMPLE_COUNT: 2,
ORDER_COUNT: 3,
SPECTRAL_TAP_COUNT: 1,
}),
DEFAULT: Object.freeze({
LIGHT_SAMPLE_COUNT: 4,
ORDER_COUNT: 4,
SPECTRAL_TAP_COUNT: 9,
}),
HIGH: Object.freeze({
LIGHT_SAMPLE_COUNT: 8,
ORDER_COUNT: 8,
SPECTRAL_TAP_COUNT: 9,
}),
});
const FOIL_CALIBRATION = Object.freeze({
FOIL_INTENSITY: 0.5,
});
const OUTPUT_CALIBRATION = Object.freeze({
LEVELS_BLACK_POINT: 0.0,
LEVELS_WHITE_POINT: 0.88,
LEVELS_MIDTONE: 1.0,
});
/** Read the non-executable page-data block emitted by the server. */
function readCardPageData()
{
return JSON.parse(document.getElementById("CardPageData").textContent);
}
/** Update the accessible card-preview status. */
function showPreviewStatus(message, kind)
{
const status = document.getElementById("PreviewStatus");
if(status != null)
{
status.textContent = message;
status.dataset.kind = kind;
}
}
/** Initialize and run the physical card preview copied from foil. */
function main()
{
try
{
const page_data = readCardPageData();
const canvas = document.getElementById("GLCanvas");
const gl = canvas.getContext("webgl2", {alpha: true});
if(!gl)
{
throw(new Error("Failed to initialize WebGL 2."));
}
const shader_definitions = Object.assign(
{}, RENDER_QUALITY.DEFAULT,
FOIL_CALIBRATION, OUTPUT_CALIBRATION);
const program = new ShaderProgram(
gl,
page_data.vertex_shader_url,
page_data.fragment_shader_url,
shader_definitions);
const renderer = new Renderer(gl, program);
const card = initScene(
gl,
program,
loadModel(page_data.model_url),
page_data);
card.front_material.ready.then(function reportReady()
{
showPreviewStatus(
page_data.foil_url == null
? "Standard finish"
: "Foil finish",
"success");
}).catch(function reportTextureFailure(error)
{
console.error(error);
showPreviewStatus(error.message, "error");
});
const light = new DiskLight(
[0, 1.0, 2.0],
[0, -1.0, -2.0],
1,
3.6);
const camera_rotation = [0.0, 0.0];
let material_update = Promise.resolve();
/** Replace the preview material with selected local image files. */
function setFiles(front_file, foil_file)
{
if(front_file == null)
{
return material_update;
}
material_update = material_update.catch(function recover()
{
return undefined;
}).then(async function update()
{
const old_material = card.front_material;
let new_material = null;
if(foil_file == null)
{
const artwork = await Texture.fromFile(
gl, front_file, {flip_y: true});
new_material = new ArtworkMaterial(gl, artwork);
}
else
{
new_material = await PhysicalFoilMaterial.fromFiles(
gl,
front_file,
foil_file,
[0, 0, 0, 255],
page_data.spectral_lut_url);
}
await new_material.ready;
for(const model of card.scene.models)
{
if(model.material == old_material)
{
model.material = new_material;
}
}
card.front_material = new_material;
old_material.dispose();
showPreviewStatus(
foil_file == null ? "Standard finish" : "Foil finish",
"success");
}).catch(function reportUpdateFailure(error)
{
console.error(error);
showPreviewStatus(error.message, "error");
throw(error);
});
return material_update;
}
/** Capture a cropped neutral PNG after pending textures load. */
async function captureThumbnail(
long_side = page_data.thumbnail_long_side)
{
await material_update;
const previous_rotation = camera_rotation.slice();
camera_rotation[0] = 0.0;
camera_rotation[1] = 0.0;
try
{
renderer.draw(
card.scene,
calculateMatrices(canvas, camera_rotation),
light);
const pixels = new Uint8Array(
canvas.width * canvas.height * 4);
gl.readPixels(
0,
0,
canvas.width,
canvas.height,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels);
const bounds = window.cardPreviewMath.findAlphaBounds(
pixels, canvas.width, canvas.height);
if(bounds == null)
{
throw(new Error(
"The card preview is fully transparent."));
}
const cropped = window.cardPreviewMath.cropWebGLPixels(
pixels, canvas.width, bounds);
const source_canvas = document.createElement("canvas");
source_canvas.width = cropped.width;
source_canvas.height = cropped.height;
const cropped_image = new ImageData(
cropped.data, cropped.width, cropped.height);
source_canvas.getContext("2d").putImageData(
cropped_image, 0, 0);
const scaled = window.cardPreviewMath.scaleDimensions(
cropped.width, cropped.height, long_side);
const output_canvas = document.createElement("canvas");
output_canvas.width = scaled.width;
output_canvas.height = scaled.height;
output_canvas.getContext("2d").drawImage(
source_canvas, 0, 0, scaled.width, scaled.height);
return await canvasPng(output_canvas);
}
finally
{
camera_rotation[0] = previous_rotation[0];
camera_rotation[1] = previous_rotation[1];
}
}
/** Form-facing preview controls for local files and thumbnails. */
window.cardPreview = {setFiles, captureThumbnail};
/** Draw one animation frame using the latest pointer rotation. */
function render()
{
const matrices = calculateMatrices(canvas, camera_rotation);
renderer.draw(card.scene, matrices, light);
requestAnimationFrame(render);
}
canvas.addEventListener("mousemove", function rotateCard(event)
{
const position = getNormalizedMousePos(event, canvas);
camera_rotation[0] = -asymptoticBound(position[1] * 10.0);
camera_rotation[1] = -asymptoticBound(position[0] * 10.0);
});
requestAnimationFrame(render);
}
catch(error)
{
console.error(error);
showPreviewStatus(error.message, "error");
}
}
window.addEventListener("load", main);