Hi everyone,
I made a small data-driven card viewer to experiment with separating content from Babylon.js rendering code.
The card definitions come from the DeckAura open 78-card tarot meanings dataset. The CSV contains names, arcana, suits, elements, upright meanings and reversed meanings.
The dataset does not contain card artwork. For this prototype, the card face is generated at runtime with DynamicTexture. This keeps the example focused on data loading, text rendering and interaction without introducing artwork licensing questions.
Data flow
Remote CSV
-> Papa Parse
-> JavaScript objects
-> DynamicTexture
-> Babylon.js material
-> Interactive card mesh
The CSV is parsed with Papa Parse because several meaning fields contain commas and quoted text. Splitting each row with String.split(",") would not be reliable.
Loading the records
const DATA_URL =
"https://huggingface.co/datasets/Blacik/" +
"deckaura-tarot-card-meanings/resolve/main/" +
"tarot_card_meanings.csv";
async function loadCards() {
const response = await fetch(DATA_URL);
if (!response.ok) {
throw new Error(`Dataset request failed: ${response.status}`);
}
const csv = await response.text();
return Papa.parse(csv, {
header: true,
skipEmptyLines: true,
}).data;
}
const cards = await loadCards();
For a standalone HTML demo, Papa Parse can be loaded before the Babylon.js application:
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
Creating the card mesh
The prototype uses one double-sided plane. The visible texture is replaced whenever the selected record or orientation changes.
const card = BABYLON.MeshBuilder.CreatePlane(
"card",
{
width: 2.6,
height: 4.2,
sideOrientation: BABYLON.Mesh.DOUBLESIDE,
},
scene
);
const cardMaterial = new BABYLON.StandardMaterial(
"cardMaterial",
scene
);
cardMaterial.backFaceCulling = false;
cardMaterial.specularColor = BABYLON.Color3.Black();
card.material = cardMaterial;
Generating the texture
function wrapText(
context,
text,
x,
startY,
maxWidth,
lineHeight
) {
const words = text.split(/\s+/);
let line = "";
let y = startY;
for (const word of words) {
const testLine = `${line}${word} `;
if (
context.measureText(testLine).width > maxWidth &&
line
) {
context.fillText(line.trim(), x, y);
line = `${word} `;
y += lineHeight;
} else {
line = testLine;
}
}
if (line) {
context.fillText(line.trim(), x, y);
}
}
function createCardTexture(record, reversed) {
const texture = new BABYLON.DynamicTexture(
"cardTexture",
{
width: 1024,
height: 1536,
},
scene,
false
);
const context = texture.getContext();
context.fillStyle = "#efe7d0";
context.fillRect(0, 0, 1024, 1536);
context.strokeStyle = "#2e2638";
context.lineWidth = 24;
context.strokeRect(34, 34, 956, 1468);
context.fillStyle = "#211b2a";
context.textAlign = "center";
context.font = "bold 70px serif";
context.fillText(record.card_name, 512, 155);
context.font = "34px sans-serif";
context.fillText(
`${record.arcana} ${record.suit || ""}`,
512,
230
);
context.font = "40px serif";
const meaning = reversed
? record.reversed_meaning
: record.upright_meaning;
wrapText(
context,
meaning,
512,
370,
820,
58
);
context.font = "bold 30px sans-serif";
context.fillText(
reversed ? "REVERSED" : "UPRIGHT",
512,
1410
);
texture.update();
return texture;
}
function updateCardTexture(record, reversed) {
const previousTexture = cardMaterial.diffuseTexture;
cardMaterial.diffuseTexture =
createCardTexture(record, reversed);
if (previousTexture) {
previousTexture.dispose();
}
}
Disposing the old DynamicTexture prevents GPU texture memory from growing every time a different card is displayed.
Flip interaction
Instead of showing the back face of the plane, the example scales the card horizontally, replaces the texture at the midpoint and expands it again. This avoids mirrored text.
let currentCard = 0;
let reversed = false;
let flipping = false;
function flipCard() {
if (flipping) {
return;
}
flipping = true;
const startTime = performance.now();
const duration = 500;
let textureChanged = false;
const observer = scene.onBeforeRenderObservable.add(() => {
const elapsed = performance.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
card.scaling.x = Math.max(
Math.abs(Math.cos(progress * Math.PI)),
0.02
);
if (progress >= 0.5 && !textureChanged) {
reversed = !reversed;
updateCardTexture(
cards[currentCard],
reversed
);
textureChanged = true;
}
if (progress >= 1) {
card.scaling.x = 1;
flipping = false;
scene.onBeforeRenderObservable.remove(observer);
}
});
}
card.actionManager =
new BABYLON.ActionManager(scene);
card.actionManager.registerAction(
new BABYLON.ExecuteCodeAction(
BABYLON.ActionManager.OnPickTrigger,
flipCard
)
);
The next step is adding a small Babylon GUI panel for selecting a card and switching between upright, reversed, love and career meanings.
For this type of viewer, would you keep using runtime DynamicTexture objects, or pre-render the text into a texture atlas when the dataset loads?