diff --git a/jest.config.js b/jest.config.js index 0ba320f418..8d2583a9ee 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,8 @@ module.exports = { setupFilesAfterEnv: [ 'jest-extended/all', ], + globalSetup: '/test/jest-global-setup.ts', + globalTeardown: '/test/jest-global-teardown.ts', transform: { '\\.vert$': 'jest-raw-loader', '\\.frag$': 'jest-raw-loader', diff --git a/package-lock.json b/package-lock.json index cfbe5dbb22..60a6d29ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-typescript": "^6.0.0", "@types/chai": "^4.3.1", + "@types/css-font-loading-module": "^0.0.7", "@types/jest": "^26.0.0", "@webdoc/cli": "^1.5.5", "copyfiles": "^2.1.0", @@ -30,6 +31,7 @@ "eslint": "^7.2.0", "eslint-plugin-jsdoc": "^39.2.9", "glob": "^7.1.3", + "http-server": "^14.1.1", "jest": "^26.0.0", "jest-electron": "^0.1.12", "jest-extended": "^1.2.1", @@ -5861,6 +5863,10 @@ "resolved": "packages/app", "link": true }, + "node_modules/@pixi/assets": { + "resolved": "packages/assets", + "link": true + }, "node_modules/@pixi/basis": { "resolved": "packages/basis", "link": true @@ -6558,6 +6564,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "dev": true + }, "node_modules/@types/earcut": { "version": "2.1.1", "license": "MIT" @@ -8148,6 +8160,15 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -8451,6 +8472,18 @@ "node": ">=0.10.0" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -9848,6 +9881,15 @@ "dev": true, "license": "MIT" }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/cross-env": { "version": "5.2.0", "dev": true, @@ -11791,6 +11833,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -12898,6 +12960,15 @@ "node": ">=0.10.0" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -12946,6 +13017,20 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -12983,6 +13068,139 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/http-server/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/http-server/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/http-server/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/http-server/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/http-server/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/http-server/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -18773,6 +18991,18 @@ "node": ">=0.10.0" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", @@ -18831,9 +19061,10 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "dev": true, - "license": "MIT" + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true }, "node_modules/minimist-options": { "version": "4.1.0", @@ -19954,6 +20185,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.1", "dev": true, @@ -20673,6 +20913,35 @@ "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", "dev": true }, + "node_modules/portfinder": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", + "integrity": "sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==", + "dev": true, + "dependencies": { + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.5" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/portfinder/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/portfinder/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "node_modules/posix-character-classes": { "version": "0.1.1", "dev": true, @@ -21334,6 +21603,12 @@ "dev": true, "license": "ISC" }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -21628,9 +21903,10 @@ } }, "node_modules/safe-buffer": { - "version": "5.1.1", - "dev": true, - "license": "MIT" + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true }, "node_modules/safe-regex": { "version": "1.1.0", @@ -21755,6 +22031,12 @@ "node": ">=10" } }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true + }, "node_modules/semver": { "version": "5.5.0", "dev": true, @@ -23401,6 +23683,18 @@ "node": ">=4" } }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/union-value": { "version": "1.0.0", "dev": true, @@ -23560,6 +23854,12 @@ "querystring": "0.2.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true + }, "node_modules/url-parse-lax": { "version": "3.0.0", "dev": true, @@ -24564,6 +24864,17 @@ "@pixi/display": "6.4.2" } }, + "packages/assets": { + "name": "@pixi/assets", + "version": "6.4.2", + "license": "MIT", + "peerDependencies": { + "@pixi/core": "6.4.2", + "@pixi/settings": "6.4.2", + "@pixi/spritesheet": "6.4.2", + "@pixi/text-bitmap": "6.4.2" + } + }, "packages/basis": { "name": "@pixi/basis", "version": "6.4.2", @@ -29534,6 +29845,9 @@ "@pixi/app": { "version": "file:packages/app" }, + "@pixi/assets": { + "version": "file:packages/assets" + }, "@pixi/basis": { "version": "file:packages/basis" }, @@ -30084,6 +30398,12 @@ "version": "1.1.1", "dev": true }, + "@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "dev": true + }, "@types/earcut": { "version": "2.1.1" }, @@ -31241,6 +31561,15 @@ "version": "1.0.0", "dev": true }, + "async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -31463,6 +31792,15 @@ } } }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "requires": { + "safe-buffer": "5.1.2" + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -32505,6 +32843,12 @@ "version": "1.0.2", "dev": true }, + "corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true + }, "cross-env": { "version": "5.2.0", "dev": true, @@ -33901,6 +34245,12 @@ "version": "2.0.2", "dev": true }, + "follow-redirects": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "dev": true + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -34705,6 +35055,12 @@ } } }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -34741,6 +35097,25 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "dependencies": { + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + } + } + }, "http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -34769,6 +35144,99 @@ } } }, + "http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "requires": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + } + } + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -39154,6 +39622,12 @@ } } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, "mime-db": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", @@ -39191,7 +39665,9 @@ } }, "minimist": { - "version": "1.2.5", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "minimist-options": { @@ -40047,6 +40523,12 @@ "version": "1.6.2", "dev": true }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, "optionator": { "version": "0.9.1", "dev": true, @@ -40593,6 +41075,34 @@ "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", "dev": true }, + "portfinder": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", + "integrity": "sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==", + "dev": true, + "requires": { + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, "posix-character-classes": { "version": "0.1.1", "dev": true @@ -41087,6 +41597,12 @@ "version": "1.0.1", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "resolve": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", @@ -41287,7 +41803,9 @@ } }, "safe-buffer": { - "version": "5.1.1", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, "safe-regex": { @@ -41391,6 +41909,12 @@ "xmlchars": "^2.2.0" } }, + "secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true + }, "semver": { "version": "5.5.0", "dev": true @@ -42556,6 +43080,15 @@ "version": "1.0.4", "dev": true }, + "union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "requires": { + "qs": "^6.4.0" + } + }, "union-value": { "version": "1.0.0", "dev": true, @@ -42682,6 +43215,12 @@ } } }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true + }, "url-parse-lax": { "version": "3.0.0", "dev": true, diff --git a/package.json b/package.json index 066aef4bf3..affbabc66d 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-typescript": "^6.0.0", "@types/chai": "^4.3.1", + "@types/css-font-loading-module": "^0.0.7", "@types/jest": "^26.0.0", "@webdoc/cli": "^1.5.5", "copyfiles": "^2.1.0", @@ -64,6 +65,7 @@ "eslint": "^7.2.0", "eslint-plugin-jsdoc": "^39.2.9", "glob": "^7.1.3", + "http-server": "^14.1.1", "jest": "^26.0.0", "jest-electron": "^0.1.12", "jest-extended": "^1.2.1", diff --git a/packages/assets/README.md b/packages/assets/README.md new file mode 100644 index 0000000000..e05dfb21df --- /dev/null +++ b/packages/assets/README.md @@ -0,0 +1,10 @@ +# @pixi/assets + +This package contains the assets class for PixiJS +managing the resolving and loading of assets. + +## Installation + +```bash +npm install @pixi/assets +``` diff --git a/packages/assets/package.json b/packages/assets/package.json new file mode 100644 index 0000000000..ca9da4eefd --- /dev/null +++ b/packages/assets/package.json @@ -0,0 +1,44 @@ +{ + "name": "@pixi/assets", + "version": "6.4.2", + "description": "Asset manager for PixiJS, loading resolving and Cacheing", + "keywords": [ + "pixi", + "caching", + "resolving", + "loaders" + ], + "author": "Mat Groves ", + "homepage": "https://github.com/pixijs/pixi.js#readme", + "license": "MIT", + "main": "dist/cjs/assets.js", + "module": "dist/esm/assets.js", + "bundle": "dist/browser/assets.js", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "*.d.ts" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/pixijs/pixi.js.git" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "bugs": { + "url": "https://github.com/pixijs/pixi.js/issues" + }, + "peerDependencies": { + "@pixi/basis": "6.4.2", + "@pixi/compressed-textures": "6.4.2", + "@pixi/constants": "6.4.2", + "@pixi/core": "6.4.2", + "@pixi/settings": "6.4.2", + "@pixi/spritesheet": "6.4.2", + "@pixi/text-bitmap": "6.4.2", + "@pixi/utils": "6.4.2" + } +} diff --git a/packages/assets/src/Assets.ts b/packages/assets/src/Assets.ts new file mode 100644 index 0000000000..8ef21e87c3 --- /dev/null +++ b/packages/assets/src/Assets.ts @@ -0,0 +1,808 @@ +import { extensions, ExtensionType } from '@pixi/core'; +import { BackgroundLoader } from './BackgroundLoader'; +import { Cache } from './cache/Cache'; +import { cacheSpritesheet, cacheTextureArray } from './cache/parsers'; +import type { + LoadAsset, + LoaderParser } from './loader'; +import { + loadJson, + loadSpritesheet, + loadTextures, + loadTxt, + loadWebFont +} from './loader'; +import { Loader } from './loader/Loader'; +import { loadBitmapFont } from './loader/parsers/loadBitmapFont'; +import type { PreferOrder, ResolveAsset, ResolverBundle, ResolverManifest, ResolveURLParser } from './resolver'; +import { spriteSheetUrlParser, textureUrlParser } from './resolver'; +import { Resolver } from './resolver/Resolver'; +import { convertToList } from './utils/convertToList'; +import { detectAvif } from './utils/detections/detectAvif'; +import { detectWebp } from './utils/detections/detectWebp'; +import { isSingleItem } from './utils/isSingleItem'; + +export type ProgressCallback = (progress: number) => void; + +/** + * Initialization options object for Asset Class. + * @memberof PIXI + */ +export interface AssetInitOptions +{ + // basic... + /** a base path for any assets loaded */ + basePath?: string; + /** + * a manifest to tell the asset loader upfront what all your assets are + * this can be the manifest object itself, or a URL to the manifest. + */ + manifest?: string | ResolverManifest; + /** + * optional preferences for which textures preferences you have when resolving assets + * for example you might set the resolution to 0.5 if the user is on a rubbish old phone + * or you might set the resolution to 2 if the user is on a retina display + */ + texturePreference?: { + /** the resolution order you prefer, can be an array (priority order - first is prefered) or a single resolutions */ + resolution?: number | number[]; + /** the formats you prefer, by default this will be: ['avif', 'webp', 'png', 'jpg', 'jpeg'] */ + format?: string | string[]; + }; + + // advanced users can add custom parsers and and preferences for how things are resolved + /** loader options to configure the loader with, currently only parsers! */ + loader?: { + /** custom parsers can be added here, for example something that could load a sound or a 3D model */ + parsers?: LoaderParser[]; + // more... + }; + /** resolver specific options */ + resolver?: { + /** + * a list of urlParsers, these can read the URL and pick put the various options. + * for example there is a texture URL parser that picks our resolution and file format. + * You can add custom ways to read URLs and extract information here. + */ + urlParsers?: ResolveURLParser[]; + /** + * a list of preferOrders that let the resolver know which asset to pick. + * already built-in we have a texture preferOrders that let the resolve know which asset to prefer + * if it has multiple assets to pick from (resolution/formats etc) + */ + preferOrders?: PreferOrder[]; + }; +} + +/** + * A one stop shop for all Pixi resource management! + * Super modern and easy to use, with enough flexibility to customize and do what you need! + * @memberof PIXI + * @namespace Assets + * + * Only one Asset Class exists accessed via the Global Asset object. + * + * It has four main responsibilities: + * 1. Allows users to map URLs to keys and resolve them according to the user's browser capabilities + * 2. Loads the resources and transforms them into assets that developers understand. + * 3. Caches the assets and provides a way to access them. + * 4: Allow developers to unload assets and clear the cache. + * + * It also has a few advanced features: + * 1. Allows developers to provide a manifest upfront of all assets and help manage them via 'bundles' + * 2. Allows users to background load assets. Shortening (or eliminating) load times and improving UX. With this feature, + * in-game loading bars can be a thing of the past! + * + * + * Do not be afraid to load things multiple times - under the hood, it will NEVER load anything more than once. + * + * for example: + * + * ``` + * promise1 = PIXI.Assets.load('bunny.png') + * promise2 = PIXI.Assets.load('bunny.png') + * + * //promise1 === promise2 + * ``` + * here both promises will be the same. Once resolved.. forever resolved! It makes for really easy resource management! + * + * Out of the box it supports the following files: + * * textures (avif, webp, png, jpg, gif) + * * sprite sheets (json) + * * bitmap fonts (xml, fnt, txt) + * * web fonts (ttf, woff, woff2) + * * json files (json) + * * text files (txt) + * + * More types can be added fairly easily by creating additional loader parsers. + * + * ### Textures + * - Textures are loaded as ImageBitmap on a worker thread where possible. + * Leading to much less janky load + parse times. + * - By default, we will prefer to load AVIF and WebP image files if you specify them. + * But if the browser doesn't support AVIF or WebP we will fall back to png and jpg. + * - Textures can also be accessed via Texture.from(...) and now use this asset manager under the hood! + * - Don't worry if you set preferences for textures that don't exist (for example you prefer 2x resolutions images + * but only 1x is available for that texture, the Asset manager will pick that up as a fallback automatically) + * #### Sprite sheets + * - it's hard to know what resolution a sprite sheet is without loading it first, to address this + * there is a naming convention we have added that will let Pixi understand the image format and resolution + * of the spritesheet via its file name: + * + * `my-spritesheet{resolution}.{imageFormat}.json` + * + * for example: + * + * `my-spritesheet@2x.webp.json` // 2x resolution, WebP sprite sheet + * `my-spritesheet@0.5x.png.json` // 0.5x resolution, png sprite sheet + * + * This is optional! you can just load a sprite sheet as normal, + * This is only useful if you have a bunch of different res / formatted spritesheets + * + * ### Fonts + * * Web fonts will be loaded with all weights. + * it is possible to load only specific weights by doing the following: + * + * ``` + * // load specific weights.. + * await PIXI.Assets.load({ + * data: { + * weights: ['normal'], // only loads the weight + * }, + * src: `outfit.woff2`, + * }); + * + * // load everything... + * await PIXI.Assets.load(`outfit.woff2`); + * ``` + * ### Background Loading + * Background loading will load stuff for you passively behind the scenes. To minimize jank, + * it will only load one asset at a time. As soon as a developer calls `PIXI.Assets.load(...)` the + * background loader is paused and requested assets are loaded as a priority. + * Don't worry if something is in there that's already loaded, it will just get skipped! + * + * You still need to call `PIXI.Assets.load(...)` to get an asset that has been loaded in the background. + * It's just that this promise will resolve instantly if the asset + * has already been loaded. + * + * ### Manifest and Bundles + * - Manifest is a JSON file that contains a list of all assets and their properties. + * - Bundles are a way to group assets together. + * + * ``` + * // manifest example + * const manifest = { + * bundles:[{ + * name:'load-screen', + * assets:[ + * { + * name: 'background', + * srcs: 'sunset.png', + * }, + * { + * name: 'bar', + * srcs: 'load-bar.{png,webp}', + * } + * ] + * }, + * { + * name:'game-screen', + * assets:[ + * { + * name: 'character', + * srcs: 'robot.png', + * }, + * { + * name: 'enemy', + * srcs: 'bad-guy.png', + * } + * ] + * }] + * }} + * + * await PIXI.Asset.init({ + * manifest + * }); + * + * // load a bundle.. + * loadScreenAssets = await PIXI.Assets.loadBundle('load-screen'); + * // load another.. + * gameScreenAssets = await PIXI.Assets.loadBundle('game-screen'); + * ``` + * @example + * const bunny = await PIXI.Assets.load('bunny.png'); + */ +export class AssetsClass +{ + /** the resolver to map various urls */ + public resolver: Resolver; + /** + * The loader, loads stuff! + * @type {PIXI.AssetLoader} + */ + public loader: Loader; + /** + * The global cache of all assets within PixiJS + * @type {PIXI.Cache} + */ + public cache: typeof Cache; + + /** takes care of loading assets in the background */ + private readonly _backgroundLoader: BackgroundLoader; + + private _initialized = false; + + constructor() + { + this.resolver = new Resolver(); + this.loader = new Loader(); + this.cache = Cache; + + this._backgroundLoader = new BackgroundLoader(this.loader); + this._backgroundLoader.active = true; + + this.reset(); + } + + /** + * Best practice is to call this function before any loading commences + * Initiating is the best time to add any customization to the way things are loaded. + * + * you do not need to call this for the Asset class to work, only if you want to set any initial properties + * @param options - options to initialize the Asset manager with + */ + public async init(options: AssetInitOptions = {}): Promise + { + if (this._initialized) + { + // #if _DEBUG + console.warn('[Assets]AssetManager already initialized, did you load before calling this Asset.init()?'); + // #endif + + return; + } + + this._initialized = true; + + if (options.basePath) + { + this.resolver.basePath = options.basePath; + } + + if (options.manifest) + { + let manifest = options.manifest; + + if (typeof manifest === 'string') + { + manifest = await this.load(manifest) as ResolverManifest; + } + + this.resolver.addManifest(manifest); + } + + const resolutionPref = options.texturePreference?.resolution ?? 1; + const resolution = (typeof resolutionPref === 'number') ? [resolutionPref] : resolutionPref; + + let format: string[]; + + if (options.texturePreference?.format) + { + const formatPref = options.texturePreference?.format; + + format = (typeof formatPref === 'string') ? [formatPref] : formatPref; + } + else + { + format = ['avif', 'webp', 'png', 'jpg', 'jpeg']; + } + + if (!(await detectWebp())) + { + format = format.filter((format) => format !== 'webp'); + } + + if (!(await detectAvif())) + { + format = format.filter((format) => format !== 'avif'); + } + + this.resolver.prefer({ + params: { + format, + resolution, + }, + }); + } + + /** + * Allows you to specify how to resolve any assets load requests. + * There are a few ways to add things here as shown below: + * @example + * // simple + * PIXI.Assets.add('bunnyBooBoo', 'bunny.png'); + * const bunny = await PIXI.Assets.load('bunnyBooBoo'); + * + * // multiple keys: + * PIXI.Assets.add(['burger', 'chicken'], 'bunny.png'); + * + * const bunny = await PIXI.Assets.load('burger'); + * const bunny2 = await PIXI.Assets.load('chicken'); + * + * // passing options to to the object + * PIXI.Assets.add( + * 'bunnyBooBooSmooth', + * 'bunny{png,webp}', + * {scaleMode:SCALE_MODES.NEAREST} // base texture options + * ); + * + * // multiple assets, + * + * // the following all do the same thing: + * + * PIXI.Assets.add('bunnyBooBoo', 'bunny{png,webp}'); + * + * PIXI.Assets.add('bunnyBooBoo', [ + * 'bunny.png', + * 'bunny.webp' + * ]); + * + * PIXI.Assets.add('bunnyBooBoo', [ + * { + * format:'png', + * src:'bunny.png', + * }, + * { + * format:'webp', + * src:'bunny.webp', + * } + * ]); + * + * const bunny = await PIXI.Assets.load('bunnyBooBoo'); // will try to load WebP if available + * @param keysIn - the key or keys that you will reference when loading this asset + * @param assetsIn - the asset or assets that will be chosen from when loading via the specified key + * @param data - asset-specific data that will be passed to the loaders + * - Useful if you want to initiate loaded objects with specific data + */ + public add(keysIn: string | string[], assetsIn: string | (ResolveAsset | string)[], data?: unknown): void + { + this.resolver.add(keysIn, assetsIn, data); + } + + /** + * Loads your assets! You pass in a key or URL and it will return a promise that + * resolves to the loaded asset. If multiple assets a requested, it will return a hash of assets. + * + * Don't worry about loading things multiple times, behind the scenes assets are only ever loaded + * once and the same promise reused behind the scenes so you can safely call this function multiple + * times with the same key and it will always return the same asset. + * @example + * // load a URL: + * const myImageTexture = await PIXI.Assets.load('http://some.url.com/image.png'); // => returns a texture + * + * PIXI.Assets.add('thumper', 'bunny.png'); + * PIXI.Assets.add('chicko', 'chicken.png'); + * + * // load multiple assets: + * const textures = await PIXI.Assets.load(['thumper', 'chicko']); // => {thumper: Texture, chicko: Texture} + * @param urls - the urls to load + * @param onProgress - optional function that is called when progress on asset loading is made. + * The function is passed a single parameter, `progress`, which represents the percentage + * (0.0 - 1.0) of the assets loaded. + * @returns - the assets that were loaded, either a single asset or a hash of assets + */ + public async load( + urls: string | string[] | LoadAsset | LoadAsset[], + onProgress?: ProgressCallback, + ): Promise> + { + if (!this._initialized) + { + await this.init(); + } + + const singleAsset = isSingleItem(urls); + + const urlArray = convertToList(urls) + .map((url) => + { + if (typeof url !== 'string') + { + this.resolver.add(url.src as string, url); + + return url.src; + } + + return url; + }); + + // check cache first... + const resolveResults = this.resolver.resolve(urlArray); + + // remap to the keys used.. + const out: Record = await this._mapLoadToResolve(resolveResults, onProgress); + + return singleAsset ? out[urlArray[0] as string] : out; + } + + /** + * This adds a bundle of assets in one go so that you can load them as a group. + * For example you could add a bundle for each screen in you pixi app + * @example + * PIXI.Assets.addBundle('animals', { + * bunny: 'bunny.png', + * chicken: 'chicken.png', + * thumper: 'thumper.png', + * }); + * + * const assets = await PIXI.Assets.loadBundle('animals'); + * @param bundleId - the id of the bundle to add + * @param assets - a record of the asset or assets that will be chosen from when loading via the specified key + */ + public addBundle(bundleId: string, assets: ResolverBundle['assets']): void + { + this.resolver.addBundle(bundleId, assets); + } + + /** + * Bundles are a way to load multiple assets at once. + * If a manifest has been provided to the init function then you can load a bundle, or bundles. + * you can also add bundles via `addBundle` + * @example + * // manifest example + * const manifest = { + * bundles:[{ + * name:'load-screen', + * assets:[ + * { + * name: 'background', + * srcs: 'sunset.png', + * }, + * { + * name: 'bar', + * srcs: 'load-bar.{png,webp}', + * } + * ] + * }, + * { + * name:'game-screen', + * assets:[ + * { + * name: 'character', + * srcs: 'robot.png', + * }, + * { + * name: 'enemy', + * srcs: 'bad-guy.png', + * } + * ] + * }] + * }} + * + * await Asset.init({ + * manifest + * }); + * + * // load a bundle.. + * loadScreenAssets = await PIXI.Assets.loadBundle('load-screen'); + * // load another.. + * gameScreenAssets = await PIXI.Assets.loadBundle('game-screen'); + * @param bundleIds - the bundle id or ids to load + * @param onProgress - optional function that is called when progress on asset loading is made. + * The function is passed a single parameter, `progress`, which represents the percentage (0.0 - 1.0) + * of the assets loaded. + * @returns all the bundles assets or a hash of assets for each bundle specified + */ + public async loadBundle(bundleIds: string | string[], onProgress?: ProgressCallback): Promise + { + if (!this._initialized) + { + await this.init(); + } + + let singleAsset = false; + + if (typeof bundleIds === 'string') + { + singleAsset = true; + bundleIds = [bundleIds]; + } + + const resolveResults = this.resolver.resolveBundle(bundleIds); + + const out: Record> = {}; + + const promises = Object.keys(resolveResults).map((bundleId) => + { + const resolveResult = resolveResults[bundleId]; + + return this._mapLoadToResolve(resolveResult, onProgress) + .then((resolveResult) => + { + out[bundleId] = resolveResult; + }); + }); + + await Promise.all(promises); + + return singleAsset ? out[bundleIds[0]] : out; + } + + /** + * Initiate a background load of some assets. it will passively begin to load these assets in the background. + * So when you actually come to loading them you will get a promise that resolves to the loaded assets immediately + * + * An example of this might be that you would background load game assets after your inital load. + * then when you got to actually load your game screen assets when a player goes to the game - the loading + * would already have stared or may even be complete, saving you having to show an interim load bar. + * @example + * PIXI.Assets.backgroundLoad('bunny.png'); + * + * // later on in your app... + * await PIXI.Assets.loadBundle('bunny.png'); // will resolve quicker as loading may have completed! + * @param urls - the url / urls you want to background load + */ + public async backgroundLoad(urls: string | string[]): Promise + { + if (!this._initialized) + { + await this.init(); + } + + if (typeof urls === 'string') + { + urls = [urls]; + } + + const resolveResults = this.resolver.resolve(urls); + + this._backgroundLoader.add(Object.values(resolveResults)); + } + + /** + * Initiate a background of a bundle, works exactly like backgroundLoad but for bundles. + * this can only be used if the loader has been initiated with a manifest + * @example + * await PIXI.Assets.init({ + * manifest: { + * bundles: [ + * { + * name:'load-screen', + * assets:[...] + * } + * ...] + * } + * }); + * + * PIXI.Assets.backgroundLoadBundle('load-screen'); + * + * // later on in your app... + * await PIXI.Assets.loadBundle('load-screen'); // will resolve quicker as loading may have completed! + * @param bundleIds - the bundleId / bundleIds you want to background load + */ + public async backgroundLoadBundle(bundleIds: string | string[]): Promise + { + if (!this._initialized) + { + await this.init(); + } + + if (typeof bundleIds === 'string') + { + bundleIds = [bundleIds]; + } + + const resolveResults = this.resolver.resolveBundle(bundleIds); + + Object.values(resolveResults).forEach((resolveResult) => + { + this._backgroundLoader.add(Object.values(resolveResult)); + }); + } + + /** + * Only intended for development purposes. + * This will wipe the resolver and caches. + * You will need to reinitialize the Asset + */ + public reset(): void + { + this.resolver.reset(); + this.loader.reset(); + this.cache.reset(); + + this._initialized = false; + } + + /** + * Instantly gets an asset already loaded from the cache. If the asset has not yet been loaded, + * it will return undefined. So it's on you! When in doubt just use `PIXI.Assets.load` instead. + * (remember, the loader will never load things more than once!) + * @param keys - The key or keys for the assets that you want to access + * @returns - The assets or hash of assets requested + */ + public get(keys: string | string[]): T | Record + { + if (typeof keys === 'string') + { + return Cache.get(keys); + } + + const assets: Record = {}; + + for (let i = 0; i < keys.length; i++) + { + assets[i] = Cache.get(keys[i]); + } + + return assets; + } + + /** + * helper function to map resolved assets back to loaded assets + * @param resolveResults - the resolve results from the resolver + * @param onProgress - the progress callback + */ + private async _mapLoadToResolve( + resolveResults: ResolveAsset | Record, + onProgress?: ProgressCallback + ): Promise> + { + const resolveArray = Object.values(resolveResults); + const resolveKeys = Object.keys(resolveResults); + + // pause background loader... + this._backgroundLoader.active = false; + + const loadedAssets = await this.loader.load(resolveArray, onProgress); + + // resume background loader... + this._backgroundLoader.active = true; + + // remap to the keys used.. + + const out: Record = {}; + + resolveArray.forEach((resolveResult, i) => + { + const asset = loadedAssets[resolveResult.src]; + + const keys = [resolveResult.src]; + + if (resolveResult.alias) + { + keys.push(...resolveResult.alias); + } + + out[resolveKeys[i]] = asset; + + Cache.set(keys, asset); + }); + + return out; + } + + /** + * Unload an asset or assets. As the Assets class is responsible for creating the assets via the `load` function + * this will make sure to destroy any assets and release them from memory. + * Once unloaded, you will need to load the asset again. + * + * Use this to help manage assets if you find that you have a large app and you want to free up memory. + * + * * it's up to you as the developer to make sure that textures are not actively being used when you unload them, + * Pixi won't break but you will end up with missing assets. Not a good look for the user! + * @example + * // load a URL: + * const myImageTexture = await PIXI.Assets.load('http://some.url.com/image.png'); // => returns a texture + * + * await PIXI.Assets.unload('http://some.url.com/image.png') + * + * myImageTexture <-- will now be destroyed. + * + * // unload multiple assets: + * const textures = await PIXI.Assets.unload(['thumper', 'chicko']); + * @param urls - the urls to unload + */ + public async unload( + urls: string | string[] | LoadAsset | LoadAsset[] + ): Promise + { + if (!this._initialized) + { + await this.init(); + } + + const urlArray = convertToList(urls) + .map((url) => + ((typeof url !== 'string') ? url.src : url)); + + // check cache first... + const resolveResults = this.resolver.resolve(urlArray); + + await this._unloadFromResolved(resolveResults); + } + + /** + * Bundles are a way to manage multiple assets at once. + * this will unload all files in a bundle. + * + * once a bundle has been unloaded, you need to load it again to have access to the assets. + * @example + * PIXI.Assets.addBundle({ + * 'thumper': 'http://some.url.com/thumper.png', + * }) + * + * const assets = await PIXI.Assets.loadBundle('thumper'); + * + * // now to unload.. + * + * await await PIXI.Assets.unloadBundle('thumper'); + * + * // all assets in the assets object will now have been destroyed and purged from the cache + * @param bundleIds - the bundle id or ids to unload + */ + public async unloadBundle(bundleIds: string | string[]): Promise + { + if (!this._initialized) + { + await this.init(); + } + + bundleIds = convertToList(bundleIds); + + const resolveResults = this.resolver.resolveBundle(bundleIds); + + const promises = Object.keys(resolveResults).map((bundleId) => + this._unloadFromResolved(resolveResults[bundleId])); + + await Promise.all(promises); + } + + private async _unloadFromResolved(resolveResult: ResolveAsset | Record) + { + const resolveArray = Object.values(resolveResult); + + resolveArray.forEach((resolveResult) => + { + Cache.remove(resolveResult.src); + }); + + await this.loader.unload(resolveArray); + } +} + +export const Assets = new AssetsClass(); + +// Handle registration of extensions +extensions.handle( + ExtensionType.LoadParser, + (extension) => { Assets.loader.addParser(extension.ref); }, + (extension) => { Assets.loader.removeParser(extension.ref); } +); +extensions.handle( + ExtensionType.ResolveParser, + (extension) => { Assets.resolver.addUrlParser(extension.ref); }, + (extension) => { Assets.resolver.removeUrlParser(extension.ref); } +); +extensions.handle( + ExtensionType.CacheParser, + (extension) => { Assets.cache.addParser(extension.ref); }, + (extension) => { Assets.cache.removeParser(extension.ref); } +); + +extensions.add( + loadTextures, + loadTxt, + loadJson, + loadSpritesheet, + loadBitmapFont, + loadWebFont, + + // cache extensions + cacheSpritesheet, + cacheTextureArray, + + // resolve extensions + textureUrlParser, + spriteSheetUrlParser +); diff --git a/packages/assets/src/BackgroundLoader.ts b/packages/assets/src/BackgroundLoader.ts new file mode 100644 index 0000000000..8d348fdb64 --- /dev/null +++ b/packages/assets/src/BackgroundLoader.ts @@ -0,0 +1,107 @@ +import type { LoadAsset } from './loader'; +import type { Loader } from './loader/Loader'; + +/** + * Quietly Loads assets in the background. + * @memberof PIXI + */ +export class BackgroundLoader +{ + /** Whether or not the loader should continue loading. */ + private _isActive: boolean; + + /** Assets to load. */ + private readonly _assetList: LoadAsset[]; + + /** Whether or not the loader is loading. */ + private _isLoading: boolean; + + /** Number of assets to load at a time. */ + private readonly _maxConcurrent: number; + + /** Should the loader log to the console. */ + public verbose: boolean; + private readonly _loader: Loader; + + /** + * @param loader + * @param verbose - should the loader log to the console + */ + constructor(loader: Loader, verbose = false) + { + this._loader = loader; + this._assetList = []; + this._isLoading = false; + this._maxConcurrent = 1; + this.verbose = verbose; + } + + /** + * Adds an array of assets to load. + * @param assetUrls - assets to load + */ + public add(assetUrls: LoadAsset[]): void + { + assetUrls.forEach((a) => + { + this._assetList.push(a); + }); + + // eslint-disable-next-line no-console + if (this.verbose)console.log('[BackgroundLoader] assets: ', this._assetList); + + if (this._isActive && !this._isLoading) + { + this._next(); + } + } + + /** + * Loads the next set of assets. Will try to load as many assets as it can at the same time. + * + * The max assets it will try to load at one time will be 4. + */ + private async _next(): Promise + { + if (this._assetList.length && this._isActive) + { + this._isLoading = true; + + const toLoad = []; + + const toLoadAmount = Math.min(this._assetList.length, this._maxConcurrent); + + for (let i = 0; i < toLoadAmount; i++) + { + toLoad.push(this._assetList.pop()); + } + + await this._loader.load(toLoad); + + this._isLoading = false; + + this._next(); + } + } + + /** + * @returns whether the class is active + */ + get active(): boolean + { + return this._isActive; + } + + /** Activate/Deactivate the loading. If set to true then it will immediately continue to load the next asset. */ + set active(value: boolean) + { + if (this._isActive === value) return; + + this._isActive = value; + + if (value && !this._isLoading) + { + this._next(); + } + } +} diff --git a/packages/assets/src/cache/Cache.ts b/packages/assets/src/cache/Cache.ts new file mode 100644 index 0000000000..c3f4cd51ba --- /dev/null +++ b/packages/assets/src/cache/Cache.ts @@ -0,0 +1,198 @@ +import { BaseTexture, Texture } from '@pixi/core'; +import { convertToList } from '../utils'; +import type { CacheParser } from './CacheParser'; + +/** + * A single Cache for all assets. + * + * When assets are added to the cache via set they normally are added to the cache as key-value pairs. + * + * With this cache, you can add parsers that will take the object and convert it to a list of assets that can be cached. + * for example a cacheSprite Sheet parser will add all of the textures found within its sprite sheet directly to the cache. + * + * This gives devs the flexibility to cache any type of object however we want. + * + * It is not intended that this class is created by developers - it is part of the Asset package. + * This is the first major system of PixiJS' main Assets class. + * @memberof PIXI + * @class Cache + */ +class CacheClass +{ + /** All loader parsers registered */ + public parsers: CacheParser[] = []; + + private readonly _cache: Map = new Map(); + private readonly _cacheMap: Map = new Map(); + + /** + * Use this to add any parsers to the `cache.set` function to + * @param newParsers - An array of parsers to add to the cache or just a single one + */ + public addParser(...newParsers: CacheParser[]): void + { + this.parsers.push(...newParsers); + } + + /** + * For exceptional situations where a cache parser might be causing some trouble, + * @param parsersToRemove - An array of parsers to remove from the cache, or just a single one + */ + public removeParser(...parsersToRemove: CacheParser[]): void + { + for (const parser of parsersToRemove) + { + const index = this.parsers.indexOf(parser); + + if (index >= 0) this.parsers.splice(index, 1); + } + } + + /** Clear all entries. */ + public reset(): void + { + this._cacheMap.clear(); + this._cache.clear(); + } + + /** + * Check if the key exists + * @param key - The key to check + */ + public has(key: string): boolean + { + return this._cache.has(key); + } + + /** + * Fetch entry by key + * @param key - The key of the entry to get + */ + public get(key: string): T + { + const result = this._cache.get(key); + + if (!result) + { + // #if _DEBUG + console.warn(`[Assets] Asset id ${key} was not found in the Cache`); + // #endif + } + + return result as T; + } + + /** + * Set a value by key or keys name + * @param key - The key or keys to set + * @param value - The value to store in the cache or from which cacheable assets will be derived. + */ + public set(key: string | string[], value: unknown): void + { + const keys = convertToList(key); + + let cacheableAssets: Record; + + for (let i = 0; i < this.parsers.length; i++) + { + const parser = this.parsers[i]; + + if (parser.test(value)) + { + cacheableAssets = parser.getCacheableAssets(keys, value); + + break; + } + } + + if (!cacheableAssets) + { + cacheableAssets = {}; + + keys.forEach((key) => + { + cacheableAssets[key] = value; + }); + } + + const cacheKeys = Object.keys(cacheableAssets); + + const cachedAssets = { + cacheKeys, + keys + }; + + // this is so we can remove them later.. + keys.forEach((key) => + { + this._cacheMap.set(key, cachedAssets); + }); + + cacheKeys.forEach((key) => + { + if (this._cache.has(key) && this._cache.get(key) !== value) + { + // #if _DEBUG + console.warn('[Cache] already has key:', key); + // #endif + } + + this._cache.set(key, cacheableAssets[key]); + }); + + // temporary to keep compatible with existing texture caching.. until we remove them! + if (value instanceof Texture) + { + const texture: Texture = value; + + keys.forEach((key) => + { + if (texture.baseTexture !== Texture.EMPTY.baseTexture) + { + BaseTexture.addToCache(texture.baseTexture, key); + } + + Texture.addToCache(texture, key); + }); + } + } + + /** + * Remove entry by key + * + * This function will also remove any associated alias from the cache also. + * @param key - The key of the entry to remove + */ + public remove(key: string): void + { + this._cacheMap.get(key); + + if (!this._cacheMap.has(key)) + { + // #if _DEBUG + console.warn(`[Assets] Asset id ${key} was not found in the Cache`); + // #endif + + return; + } + + const cacheMap = this._cacheMap.get(key); + + const cacheKeys = cacheMap.cacheKeys; + + cacheKeys.forEach((key) => + { + this._cache.delete(key); + }); + + cacheMap.keys.forEach((key: string) => + { + this._cacheMap.delete(key); + }); + } +} + +export const Cache = new CacheClass(); diff --git a/packages/assets/src/cache/CacheParser.ts b/packages/assets/src/cache/CacheParser.ts new file mode 100644 index 0000000000..7a8d868cac --- /dev/null +++ b/packages/assets/src/cache/CacheParser.ts @@ -0,0 +1,35 @@ +import type { ExtensionMetadata } from '@pixi/core'; + +/** + * For every asset that is cached, it will call the parsers test function + * the flow is as follows: + * + * 1. `cacheParser.test()`: Test the asset. + * 2. `cacheParser.getCacheableAssets()`: If the test passes call the getCacheableAssets function with the asset + * + * Useful if you want to add more than just a raw asset to the cache + * (for example a spritesheet will want to make all its sub textures easily accessible in the cache) + */ +export interface CacheParser +{ + extension?: ExtensionMetadata; + + /** A config to adjust the parser */ + config?: Record + + /** + * Gets called by the cache when a dev caches an asset + * @param asset - the asset to test + */ + test: (asset: T) => boolean; + + /** + * If the test passes, this function is called to get the cacheable assets + * an example may be that a spritesheet object will return all the sub textures it has so they can + * be cached. + * @param keys - The keys to cache the assets under + * @param asset - The asset to get the cacheable assets from + * @returns A key-value pair of cacheable assets + */ + getCacheableAssets: (keys: string[], asset: T) => Record; +} diff --git a/packages/assets/src/cache/index.ts b/packages/assets/src/cache/index.ts new file mode 100644 index 0000000000..51b4329803 --- /dev/null +++ b/packages/assets/src/cache/index.ts @@ -0,0 +1,3 @@ +export * from './Cache'; +export * from './parsers'; +export * from './CacheParser'; diff --git a/packages/assets/src/cache/parsers/cacheSpritesheet.ts b/packages/assets/src/cache/parsers/cacheSpritesheet.ts new file mode 100644 index 0000000000..921865ec9f --- /dev/null +++ b/packages/assets/src/cache/parsers/cacheSpritesheet.ts @@ -0,0 +1,39 @@ +import { ExtensionType } from '@pixi/core'; +import { Spritesheet } from '@pixi/spritesheet'; +import { dirname } from '../../utils'; +import type { CacheParser } from '../CacheParser'; + +function getCacheableAssets(keys: string[], asset: Spritesheet, ignoreMultiPack: boolean) +{ + const out: Record = {}; + + keys.forEach((key: string) => + { + out[key] = asset; + }); + + Object.keys(asset.textures).forEach((key) => + { + out[key] = asset.textures[key]; + }); + + if (!ignoreMultiPack) + { + const basePath = dirname(keys[0]); + + asset.linkedSheets.forEach((item: Spritesheet, i) => + { + const out2 = getCacheableAssets([`${basePath}/${asset.data.meta.related_multi_packs[i]}`], item, true); + + Object.assign(out, out2); + }); + } + + return out; +} + +export const cacheSpritesheet: CacheParser = { + extension: ExtensionType.CacheParser, + test: (asset: Spritesheet) => asset instanceof Spritesheet, + getCacheableAssets: (keys: string[], asset: Spritesheet) => getCacheableAssets(keys, asset, false) +}; diff --git a/packages/assets/src/cache/parsers/cacheTextureArray.ts b/packages/assets/src/cache/parsers/cacheTextureArray.ts new file mode 100644 index 0000000000..aacc74b9a0 --- /dev/null +++ b/packages/assets/src/cache/parsers/cacheTextureArray.ts @@ -0,0 +1,23 @@ +import { ExtensionType, Texture } from '@pixi/core'; +import type { CacheParser } from '../CacheParser'; + +export const cacheTextureArray: CacheParser = { + extension: ExtensionType.CacheParser, + + test: (asset: any[]) => Array.isArray(asset) && asset.every((t) => t instanceof Texture), + + getCacheableAssets: (keys: string[], asset: Texture[]) => + { + const out: Record = {}; + + keys.forEach((key: string) => + { + asset.forEach((item: Texture, i: number) => + { + out[key + (i === 0 ? '' : i + 1)] = item; + }); + }); + + return out; + } +}; diff --git a/packages/assets/src/cache/parsers/index.ts b/packages/assets/src/cache/parsers/index.ts new file mode 100644 index 0000000000..6c4b99e747 --- /dev/null +++ b/packages/assets/src/cache/parsers/index.ts @@ -0,0 +1,3 @@ +export * from './cacheSpritesheet'; +export * from './cacheTextureArray'; + diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts new file mode 100644 index 0000000000..d3b2cfaff2 --- /dev/null +++ b/packages/assets/src/index.ts @@ -0,0 +1,6 @@ +export * from './Assets'; +export * from './utils'; +export * from './cache'; +export * from './loader'; +export * from './resolver'; + diff --git a/packages/assets/src/loader/Loader.ts b/packages/assets/src/loader/Loader.ts new file mode 100644 index 0000000000..61aecd888f --- /dev/null +++ b/packages/assets/src/loader/Loader.ts @@ -0,0 +1,219 @@ +import { convertToList, isSingleItem, makeAbsoluteUrl } from '../utils'; +import type { LoaderParser } from './parsers/LoaderParser'; +import type { PromiseAndParser, LoadAsset } from './types'; + +/** + * The Loader is responsible for loading all assets, such as images, spritesheets, audio files, etc. + * It does not do anything clever with URLs - it just loads stuff! + * Behind the scenes all things are cached using promises. This means it's impossible to load an asset more than once. + * Through the use of LoaderParsers, the loader can understand how to load any kind of file! + * + * It is not intended that this class is created by developers - its part of the Asset class + * This is the second major system of PixiJS' main Assets class + * @memberof PIXI + * @class AssetLoader + */ +export class Loader +{ + /** All loader parsers registered */ + public parsers: LoaderParser[] = []; + + /** Cache loading promises that ae currently active */ + public promiseCache: Record = {}; + + /** function used for testing */ + public reset(): void + { + this.promiseCache = {}; + } + + /** + * Use this to add any parsers to the loadAssets function to use + * @param newParsers - An array of parsers to add to the loader, or just a single one + */ + public addParser(...newParsers: LoaderParser[]): void + { + this.parsers.push(...newParsers); + } + + /** + * Use this to remove any parsers you've added or any of the default ones. + * @param parsersToRemove - An array of parsers to remove from the loader, or just a single one + */ + public removeParser(...parsersToRemove: LoaderParser[]): void + { + for (const parser of parsersToRemove) + { + const index = this.parsers.indexOf(parser); + + if (index >= 0) this.parsers.splice(index, 1); + } + } + + /** + * Used internally to generate a promise for the asset to be loaded. + * @param url - The URL to be loaded + * @param data - any custom additional information relevant to the asset being loaded + * @returns - a promise that will resolve to an Asset for example a Texture of a JSON object + */ + private _getLoadPromiseAndParser(url: string, data?: LoadAsset): PromiseAndParser + { + const result: PromiseAndParser = { + promise: null, + parser: null + }; + + result.promise = (async () => + { + let asset = null; + + for (let i = 0; i < this.parsers.length; i++) + { + const parser = this.parsers[i]; + + if (parser.load && parser.test?.(url, data, this)) + { + asset = await parser.load(url, data, this); + result.parser = parser; + + break; + } + } + + if (!result.parser) + { + // #if _DEBUG + // eslint-disable-next-line max-len + console.warn(`[Assets] ${url} could not be loaded as we don't know how to parse it, ensure the correct parser has being added`); + // #endif + + return null; + } + + for (let i = 0; i < this.parsers.length; i++) + { + const parser = this.parsers[i]; + + if (parser.parse) + { + if (parser.parse && parser.testParse?.(asset, data, this)) + { + // transform the asset.. + asset = await parser.parse(asset, data, this) || asset; + + result.parser = parser; + } + } + } + + return asset; + })(); + + return result; + } + + /** + * Loads an asset(s) using the parsers added to the Loader. + * @example + * // single asset: + * const asset = await Loader.load('cool.png'); + * console.log(asset); + * @example + * // multiple assets: + * const assets = await Loader.load(['cool.png', 'cooler.png']); + * console.log(assets); + * @param assetsToLoadIn - urls that you want to load, or a single one! + * @param onProgress - a function that gets called when the progress changes + */ + public async load( + assetsToLoadIn: string | string[] | LoadAsset | LoadAsset[], + onProgress?: (progress: number) => void, + ): Promise<{[key: string]: any} | any> + { + let count = 0; + + const assets: Record> = {}; + + const singleAsset = isSingleItem(assetsToLoadIn); + + const assetsToLoad = convertToList(assetsToLoadIn, (item) => ({ + src: item, + })); + + const total = assetsToLoad.length; + + const promises: Promise[] = assetsToLoad.map(async (asset: LoadAsset) => + { + const url = makeAbsoluteUrl(asset.src); + + if (!assets[asset.src]) + { + try + { + if (!this.promiseCache[url]) + { + this.promiseCache[url] = this._getLoadPromiseAndParser(url, asset); + } + + assets[asset.src] = await this.promiseCache[url].promise; + + // Only progress if nothing goes wrong + if (onProgress) onProgress(++count / total); + } + catch (e) + { + // Delete eventually registered file and promises from internal cache + // so they can be eligible for another loading attempt + delete this.promiseCache[url]; + delete assets[asset.src]; + + // Stop further execution + throw new Error(`[Loader.load] Failed to load ${url}.\n${e}`); + } + } + }); + + await Promise.all(promises); + + return singleAsset ? assets[assetsToLoad[0].src] : assets; + } + + /** + * Unloads an asset(s). Any unloaded assets will be destroyed, freeing up memory for your app. + * The parser that created the asset, will be the one that unloads it. + * @example + * // single asset: + * const asset = await Loader.load('cool.png'); + * + * await Loader.unload('cool.png'); + * + * console.log(asset.destroyed); // true + * @param assetsToUnloadIn - urls that you want to unload, or a single one! + */ + public async unload( + assetsToUnloadIn: string | string[] | LoadAsset | LoadAsset[], + ): Promise + { + const assetsToUnload = convertToList(assetsToUnloadIn, (item) => ({ + src: item, + })); + + const promises: Promise[] = assetsToUnload.map(async (asset: LoadAsset) => + { + const url = makeAbsoluteUrl(asset.src); + + const loadPromise = this.promiseCache[url]; + + if (loadPromise) + { + const loadedAsset = await loadPromise.promise; + + loadPromise.parser?.unload?.(loadedAsset, asset, this); + + delete this.promiseCache[url]; + } + }); + + await Promise.all(promises); + } +} diff --git a/packages/assets/src/loader/index.ts b/packages/assets/src/loader/index.ts new file mode 100644 index 0000000000..78f56d4593 --- /dev/null +++ b/packages/assets/src/loader/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './parsers'; diff --git a/packages/assets/src/loader/parsers/LoaderParser.ts b/packages/assets/src/loader/parsers/LoaderParser.ts new file mode 100644 index 0000000000..b98b5dadf1 --- /dev/null +++ b/packages/assets/src/loader/parsers/LoaderParser.ts @@ -0,0 +1,69 @@ +import type { ExtensionMetadata } from '@pixi/core'; +import type { Loader } from '../Loader'; +import type { LoadAsset } from '../types'; + +/** + * All functions are optional here. The flow: + * + * for every asset, + * + * 1. `parser.test()`: Test the asset url. + * 2. `parser.load()`: If test passes call the load function with the url + * 3. `parser.testParse()`: Test to see if the asset should be parsed by the plugin + * 4. `parse.parse()`: If test is parsed, then run the parse function on the asset. + * + * some plugins may only be used for parsing, + * some only for loading + * and some for both! + */ +export interface LoaderParser +{ + extension?: ExtensionMetadata; + + /** A config to adjust the parser */ + config?: Record + /** + * each URL to load will be tested here, + * if the test is passed the assets are loaded using the load function below. + * Good place to test for things like file extensions! + * @param url - The URL to test + * @param loadAsset - Any custom additional information relevant to the asset being loaded + * @param loader - The loader instance + */ + test?: (url: string, loadAsset?: LoadAsset, loader?: Loader) => boolean; + + /** + * This is the promise that loads the URL provided + * resolves with a loaded asset if returned by the parser. + * @param url - The URL to load + * @param loadAsset - Any custom additional information relevant to the asset being loaded + * @param loader - The loader instance + */ + load?: (url: string, loadAsset?: LoadAsset, loader?: Loader) => Promise; + + /** + * This function is used to test if the parse function should be run on the asset + * If this returns true then parse is called with the asset + * @param asset - The loaded asset data + * @param loadAsset - Any custom additional information relevant to the asset being loaded + * @param loader - The loader instance + */ + testParse?: (asset: ASSET, loadAsset?: LoadAsset, loader?: Loader) => boolean; + + /** + * Gets called on the asset it testParse passes. Useful to convert a raw asset into something more useful than + * @param asset - The loaded asset data + * @param loadAsset - Any custom additional information relevant to the asset being loaded + * @param loader - The loader instance + */ + parse?: (asset: ASSET, loadAsset?: LoadAsset, loader?: Loader) => Promise; + + /** + * If an asset is parsed using this parser, the unload function will be called when the user requests an asset + * to be unloaded. This is useful for things like sounds or textures that can be unloaded from memory + * @param asset - The asset to unload/destroy + * @param loadAsset - Any custom additional information relevant to the asset being loaded + * @param loader - The loader instance + */ + unload?: (asset: ASSET, loadAsset?: LoadAsset, loader?: Loader) => void; +} diff --git a/packages/assets/src/loader/parsers/WorkerManager.ts b/packages/assets/src/loader/parsers/WorkerManager.ts new file mode 100644 index 0000000000..5b259cbbf1 --- /dev/null +++ b/packages/assets/src/loader/parsers/WorkerManager.ts @@ -0,0 +1,147 @@ +let UUID = 0; +const MAX_WORKERS = navigator.hardwareConcurrency || 4; + +const workerCode = { + id: 'loadImageBitmap', + code: ` + self.onmessage = function(event) { + + async function loadImageBitmap(url) + { + const response = await fetch(url); + const imageBlob = await response.blob(); + const imageBitmap = await createImageBitmap(imageBlob); + return imageBitmap; + } + + loadImageBitmap(event.data.data[0]).then(imageBitmap => { + self.postMessage({ + data: imageBitmap, + uuid: event.data.uuid, + id: event.data.id, + }, [imageBitmap]); + }).catch(error => { + self.postMessage({ + data: null, + uuid: event.data.uuid, + id: event.data.id, + }); + }); + }`, +}; + +const blob = new Blob([workerCode.code], { type: 'application/javascript' }); +const workerURL = URL.createObjectURL(blob); + +class WorkerManagerClass +{ + public worker: Worker; + private resolveHash: {[key: string]: (...param: any[]) => void}; + private readonly workerPool: Worker[]; + private readonly queue: { id: string; arguments: any[]; resolve: (...param: any[]) => void }[]; + private _initialized = false; + private _createdWorkers = 0; + + constructor() + { + this.workerPool = []; + this.queue = []; + + this.resolveHash = {}; + } + + public loadImageBitmap(src: string): Promise + { + return this._run('loadImageBitmap', [src]) as Promise; + } + + private async _initWorkers() + { + if (this._initialized) return; + + this._initialized = true; + } + + private getWorker(): Worker + { + let worker = this.workerPool.pop(); + + if (!worker && this._createdWorkers < MAX_WORKERS) + { + // only create as many as MAX_WORKERS allows.. + this._createdWorkers++; + worker = new Worker(workerURL); + + worker.addEventListener('message', (event: MessageEvent) => + { + this.complete(event.data); + + this.returnWorker(event.target as Worker); + this.next(); + }); + } + + return worker; + } + + private returnWorker(worker: Worker) + { + this.workerPool.push(worker); + } + + private complete(data: any): void + { + const result = data.data; + + this.resolveHash[data.uuid](result); + + this.resolveHash[data.uuid] = null; + } + + private _run(id: string, args: any[]): Promise + { + this._initWorkers(); + // push into the queue... + + const promise = new Promise((resolve) => + { + this.queue.push({ id, arguments: args, resolve }); + }); + + this.next(); + + return promise; + } + + private next(): void + { + // nothing to do + if (!this.queue.length) return; + + const worker = this.getWorker(); + + // no workers available... + if (!worker) + { + return; + } + + const toDo = this.queue.pop(); + + const id = toDo.id; + + this.resolveHash[UUID] = toDo.resolve; + + worker.postMessage({ + data: toDo.arguments, + uuid: UUID++, + id, + }); + } +} + +const WorkerManager = new WorkerManagerClass(); + +export { + WorkerManager, +}; diff --git a/packages/assets/src/loader/parsers/index.ts b/packages/assets/src/loader/parsers/index.ts new file mode 100644 index 0000000000..eb12fd6763 --- /dev/null +++ b/packages/assets/src/loader/parsers/index.ts @@ -0,0 +1,11 @@ +export * from './loadBitmapFont'; +export * from './LoaderParser'; +export * from './loadJson'; +export * from './loadSpritesheet'; +export * from './loadTexture'; +export * from './loadTxt'; +export * from './loadWebFont'; +export * from './loadBasis'; +export * from './loadDDS'; +export * from './loadKTX'; + diff --git a/packages/assets/src/loader/parsers/loadBasis.ts b/packages/assets/src/loader/parsers/loadBasis.ts new file mode 100644 index 0000000000..d837f0ed1a --- /dev/null +++ b/packages/assets/src/loader/parsers/loadBasis.ts @@ -0,0 +1,79 @@ +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; + +import type { LoaderParser } from './LoaderParser'; +import { BasisParser, BASIS_FORMATS, BASIS_FORMAT_TO_TYPE, TranscoderWorker } from '@pixi/basis'; +import type { TYPES } from '@pixi/constants'; +import { ALPHA_MODES, FORMATS, MIPMAP_MODES } from '@pixi/constants'; +import { CompressedTextureResource } from '@pixi/compressed-textures'; +import type { LoadAsset } from '../types'; +import type { Loader } from '../Loader'; +import type { LoadTextureData } from './loadTexture'; + +const validImages = ['basis']; + +/** Load BASIS textures! */ +export const loadBasis = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + const tempURL = url.split('?')[0]; + const extension = tempURL.split('.').pop(); + + return validImages.includes(extension.toLowerCase()); + }, + + async load(url: string, asset: LoadAsset, loader: Loader): Promise + { + await TranscoderWorker.onTranscoderInitialized; + + // get an array buffer... + const response = await fetch(url); + + const arrayBuffer = await response.arrayBuffer(); + + const resources = await BasisParser.transcode(arrayBuffer); + + const type: TYPES = BASIS_FORMAT_TO_TYPE[resources.basisFormat]; + const format: FORMATS = resources.basisFormat !== BASIS_FORMATS.cTFRGBA32 ? FORMATS.RGB : FORMATS.RGBA; + + const textures = resources.map((resource) => + { + const base = new BaseTexture(resource, { + mipmap: resource instanceof CompressedTextureResource && resource.levels > 1 + ? MIPMAP_MODES.ON_MANUAL + : MIPMAP_MODES.OFF, + alphaMode: ALPHA_MODES.NO_PREMULTIPLIED_ALPHA, + type, + format, + ...asset.data, + }); + + const texture = new Texture(base); + + // make sure to nuke the promise if a texture is destroyed.. + texture.baseTexture.on('dispose', () => + { + delete loader.promiseCache[url]; + }); + + return texture; + }); + + return textures.length === 1 ? textures[0] : textures; + }, + + unload(texture): void + { + if (Array.isArray(texture)) + { + texture.forEach((t) => t.destroy(true)); + } + else + { + texture.destroy(true); + } + } + +} as LoaderParser; + diff --git a/packages/assets/src/loader/parsers/loadBitmapFont.ts b/packages/assets/src/loader/parsers/loadBitmapFont.ts new file mode 100644 index 0000000000..a73274226f --- /dev/null +++ b/packages/assets/src/loader/parsers/loadBitmapFont.ts @@ -0,0 +1,79 @@ +import type { Texture } from '@pixi/core'; +import { ExtensionType } from '@pixi/core'; +import type { BitmapFontData } from '@pixi/text-bitmap'; +import { BitmapFont, TextFormat, XMLFormat, XMLStringFormat } from '@pixi/text-bitmap'; +import { dirname, extname, join } from '../../utils/path'; + +import type { Loader } from '../Loader'; +import type { LoadAsset } from '../types'; + +import type { LoaderParser } from './LoaderParser'; + +async function _loadBitmap(src: string, data: BitmapFontData, loader: Loader): Promise +{ + const pages = data.page; + + const textureUrls = []; + + for (let i = 0; i < pages.length; ++i) + { + const pageFile = pages[i].file; + + const imagePath = join(dirname(src), pageFile); + + textureUrls.push(imagePath); + } + + const textures: Texture[] = Object.values(await loader.load(textureUrls)); + + return BitmapFont.install(data, textures, true); +} +const validExtensions = ['.xml', '.fnt']; + +/** simple loader plugin for loading in bitmap fonts! */ +export const loadBitmapFont = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + return validExtensions.includes(extname(url)); + }, + + testParse(data: string): boolean + { + const isText = TextFormat.test(data); + const isXMLText = XMLStringFormat.test(data); + + return isText || isXMLText; + }, + + async parse(asset: string, data: LoadAsset, loader: Loader): Promise + { + const isText = TextFormat.test(asset); + + if (isText) + { + const parsed = TextFormat.parse(asset); + + return await _loadBitmap(data.src, parsed, loader); + } + + return await _loadBitmap(data.src, XMLStringFormat.parse(asset), loader); + }, + + async load(url: string, _options: LoadAsset, loader: Loader): Promise + { + const response = await fetch(url); + + const text = await response.text(); + + const data = new window.DOMParser().parseFromString(text, 'text/xml'); + + return await _loadBitmap(url, XMLFormat.parse(data), loader); + }, + + unload(bitmapFont: Texture): void + { + bitmapFont.destroy(true); + } +} as LoaderParser; diff --git a/packages/assets/src/loader/parsers/loadDDS.ts b/packages/assets/src/loader/parsers/loadDDS.ts new file mode 100644 index 0000000000..49922d6d52 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadDDS.ts @@ -0,0 +1,69 @@ +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; +import { getResolutionOfUrl } from '@pixi/utils'; +import { parseDDS } from '@pixi/compressed-textures'; +import type { Loader } from '../Loader'; + +import type { LoaderParser } from './LoaderParser'; +import type { LoadAsset } from '../types'; +import { ALPHA_MODES, MIPMAP_MODES } from '@pixi/constants'; +import type { LoadTextureData } from './loadTexture'; + +const validImages = ['dds']; + +/** Load our DDS textures! */ +export const loadDDS: LoaderParser = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + const tempURL = url.split('?')[0]; + const extension = tempURL.split('.').pop(); + + return validImages.includes(extension.toLowerCase()); + }, + + async load(url: string, asset: LoadAsset, loader: Loader): Promise + { + // get an array buffer... + const response = await fetch(url); + + const arrayBuffer = await response.arrayBuffer(); + + const resources = parseDDS(arrayBuffer); + + const textures = resources.map((resource) => + { + const base = new BaseTexture(resource, { + mipmap: MIPMAP_MODES.OFF, + alphaMode: ALPHA_MODES.NO_PREMULTIPLIED_ALPHA, + resolution: getResolutionOfUrl(url), + ...asset.data, + }); + + const texture = new Texture(base); + + texture.baseTexture.on('dispose', () => + { + delete loader.promiseCache[url]; + }); + + return texture; + }); + + return textures.length === 1 ? textures[0] : textures; + }, + + unload(texture: Texture | Texture[]): void + { + if (Array.isArray(texture)) + { + texture.forEach((t) => t.destroy(true)); + } + else + { + texture.destroy(true); + } + } + +} as LoaderParser; + diff --git a/packages/assets/src/loader/parsers/loadJson.ts b/packages/assets/src/loader/parsers/loadJson.ts new file mode 100644 index 0000000000..8031af2ea5 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadJson.ts @@ -0,0 +1,22 @@ +import { ExtensionType } from '@pixi/core'; +import { extname } from '../../utils/path'; +import type { LoaderParser } from './LoaderParser'; + +/** simple loader plugin for loading json data */ +export const loadJson = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + return (extname(url).includes('.json')); + }, + + async load(url: string): Promise + { + const response = await fetch(url); + + const json = await response.json(); + + return json as T; + }, +} as LoaderParser; diff --git a/packages/assets/src/loader/parsers/loadKTX.ts b/packages/assets/src/loader/parsers/loadKTX.ts new file mode 100644 index 0000000000..e1c8db4a75 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadKTX.ts @@ -0,0 +1,84 @@ +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; +import { parseKTX } from '@pixi/compressed-textures'; +import type { Loader } from '../Loader'; + +import type { LoaderParser } from './LoaderParser'; + +import { getResolutionOfUrl } from '@pixi/utils'; +import type { LoadAsset } from '../types'; +import { ALPHA_MODES, MIPMAP_MODES } from '@pixi/constants'; +import type { LoadTextureData } from './loadTexture'; + +const validImages = ['ktx']; + +/** Loads KTX textures! */ +export const loadKTX = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + const tempURL = url.split('?')[0]; + const extension = tempURL.split('.').pop(); + + return validImages.includes(extension.toLowerCase()); + }, + + async load(url: string, asset: LoadAsset, loader: Loader): Promise + { + // get an array buffer... + const response = await fetch(url); + + const arrayBuffer = await response.arrayBuffer(); + + const { compressed, uncompressed, kvData } = parseKTX(url, arrayBuffer); + + const resources = compressed ?? uncompressed; + + const options = { + mipmap: MIPMAP_MODES.OFF, + alphaMode: ALPHA_MODES.NO_PREMULTIPLIED_ALPHA, + resolution: getResolutionOfUrl(url), + ...asset.data, + }; + + const textures = resources.map((resource) => + { + if (resources === uncompressed) + { + Object.assign(options, { + type: (resource as typeof uncompressed[0]).type, + format: (resource as typeof uncompressed[0]).format, + }); + } + + const base = new BaseTexture(resource, options); + + base.ktxKeyValueData = kvData; + + const texture = new Texture(base); + + texture.baseTexture.on('dispose', () => + { + delete loader.promiseCache[url]; + }); + + return texture; + }); + + return textures.length === 1 ? textures[0] : textures; + }, + + unload(texture: Texture | Texture[]): void + { + if (Array.isArray(texture)) + { + texture.forEach((t) => t.destroy(true)); + } + else + { + texture.destroy(true); + } + } + +} as LoaderParser; + diff --git a/packages/assets/src/loader/parsers/loadSpritesheet.ts b/packages/assets/src/loader/parsers/loadSpritesheet.ts new file mode 100644 index 0000000000..2a2c07c6c2 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadSpritesheet.ts @@ -0,0 +1,108 @@ +import type { Texture } from '@pixi/core'; +import { ExtensionType } from '@pixi/core'; +import type { ISpritesheetData } from '@pixi/spritesheet'; +import { Spritesheet } from '@pixi/spritesheet'; + +import { dirname, extname } from '../../utils/path'; + +import type { Loader } from '../Loader'; +import type { LoadAsset } from '../types'; +import type { LoaderParser } from './LoaderParser'; + +interface SpriteSheetJson extends ISpritesheetData +{ + meta: { + image: string; + scale: string; + // eslint-disable-next-line camelcase + related_multi_packs?: string[]; + }; +} + +/** + * Loader plugin that parses sprite sheets! + * once the JSON has been loaded this checks to see if the JSON is spritesheet data. + * If it is, we load the spritesheets image and parse the data into PIXI.Spritesheet + * All textures in the sprite sheet are then added to the cache + */ +export const loadSpritesheet = { + extension: ExtensionType.LoadParser, + + testParse(asset: SpriteSheetJson, options: LoadAsset): boolean + { + return (extname(options.src).includes('.json') && !!asset.frames); + }, + + async parse(asset: SpriteSheetJson, options: LoadAsset, loader: Loader): Promise + { + let basePath = dirname(options.src); + + if (basePath && basePath.lastIndexOf('/') !== (basePath.length - 1)) + { + basePath += '/'; + } + + const imagePath = basePath + asset.meta.image; + + const assets = await loader.load([imagePath]) as Record; + + const texture = assets[imagePath]; + + const spritesheet = new Spritesheet( + texture.baseTexture, + asset, + options.src, + ); + + await spritesheet.parse(); + + // Check and add the multi atlas + // Heavily influenced and based on https://github.com/rocket-ua/pixi-tps-loader/blob/master/src/ResourceLoader.js + // eslint-disable-next-line camelcase + const multiPacks = asset?.meta?.related_multi_packs; + + if (Array.isArray(multiPacks)) + { + const promises: Promise[] = []; + + for (const item of multiPacks) + { + if (typeof item !== 'string') + { + continue; + } + + const itemUrl = basePath + item; + + // Check if the file wasn't already added as multipack + if (options.data?.ignoreMultiPack) + { + continue; + } + + promises.push(loader.load({ + src: itemUrl, + data: { + ignoreMultiPack: true, + } + })); + } + + const res = await Promise.all(promises); + + spritesheet.linkedSheets = res; + res.forEach((item) => + { + item.linkedSheets = [spritesheet].concat(spritesheet.linkedSheets.filter((sp) => (sp !== item))); + }); + } + + return spritesheet; + }, + + unload(spritesheet: Spritesheet) + { + spritesheet.destroy(true); + }, + +} as LoaderParser; diff --git a/packages/assets/src/loader/parsers/loadTexture.ts b/packages/assets/src/loader/parsers/loadTexture.ts new file mode 100644 index 0000000000..164eb7f4b1 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadTexture.ts @@ -0,0 +1,104 @@ +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; +import { getResolutionOfUrl } from '@pixi/utils'; +import type { Loader } from '../Loader'; +import type { LoadAsset } from '../types'; + +import type { LoaderParser } from './LoaderParser'; +import { WorkerManager } from './WorkerManager'; + +const validImages = ['jpg', 'png', 'jpeg', 'avif', 'webp']; + +/** + * Returns a promise that resolves an ImageBitmaps. + * This function is designed to be used by a worker. + * Part of WorkerManager! + * @param url - The image to load an image bitmap for + */ +export async function loadImageBitmap(url: string): Promise +{ + const response = await fetch(url); + const imageBlob = await response.blob(); + const imageBitmap = await createImageBitmap(imageBlob); + + return imageBitmap; +} + +export type LoadTextureData = { + baseTexture: BaseTexture; +}; + +/** + * Loads our textures! + * this makes use of imageBitmaps where available. + * We load the ImageBitmap on a different thread using the WorkerManager + * We can then use the ImageBitmap as a source for a Pixi Texture + */ +export const loadTextures = { + extension: ExtensionType.LoadParser, + + config: { + preferWorkers: true, + }, + + test(url: string): boolean + { + const tempURL = url.split('?')[0]; + const extension = tempURL.split('.').pop(); + + return validImages.includes(extension); + }, + + async load(url: string, asset: LoadAsset, loader: Loader): Promise + { + let src: any = null; + + if (window.createImageBitmap) + { + src = this.config.preferWorkers ? await WorkerManager.loadImageBitmap(url) : await loadImageBitmap(url); + } + else + { + src = await new Promise((resolve) => + { + src = new Image(); + src.crossOrigin = 'anonymous'; + + src.src = url; + if (src.complete) + { + resolve(src); + } + else + { + src.onload = (): void => + { + resolve(src); + }; + } + }); + } + + const base = new BaseTexture(src, { + resolution: getResolutionOfUrl(url), + ...asset.data, + }); + + base.resource.src = url; + + const texture = new Texture(base); + + // make sure to nuke the promise if a texture is destroyed.. + texture.baseTexture.on('dispose', () => + { + delete loader.promiseCache[url]; + }); + + return texture; + }, + + unload(texture: Texture): void + { + texture.destroy(true); + } + +} as LoaderParser; diff --git a/packages/assets/src/loader/parsers/loadTxt.ts b/packages/assets/src/loader/parsers/loadTxt.ts new file mode 100644 index 0000000000..bc7ac77926 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadTxt.ts @@ -0,0 +1,22 @@ +import { ExtensionType } from '@pixi/core'; +import { extname } from '../../utils/path'; +import type { LoaderParser } from './LoaderParser'; + +/** Simple loader plugin for loading text data */ +export const loadTxt = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + return (extname(url).includes('.txt')); + }, + + async load(url: string): Promise + { + const response = await fetch(url); + + const txt = await response.text(); + + return txt; + }, +} as LoaderParser; diff --git a/packages/assets/src/loader/parsers/loadWebFont.ts b/packages/assets/src/loader/parsers/loadWebFont.ts new file mode 100644 index 0000000000..8c92d2a2f6 --- /dev/null +++ b/packages/assets/src/loader/parsers/loadWebFont.ts @@ -0,0 +1,102 @@ +import { ExtensionType } from '@pixi/core'; +import { basename, extname } from '../../utils/path'; +import type { LoadAsset } from '../types'; +import type { LoaderParser } from './LoaderParser'; + +const validWeights = ['normal', 'bold', + '100', '200', '300', '400', '500', '600', '700', '800', '900', +]; +const validFonts = ['woff', 'woff2', 'ttf', 'otf']; + +export type LoadFontData = { + family: string; + display: string; + featureSettings: string; + stretch: string; + style: string; + unicodeRange: string; + variant: string; + weights: string[]; +}; + +/** + * Return font face name from a file name + * Ex.: 'fonts/tital-one.woff' turns into 'Titan One' + * @param url - File url + */ +export function getFontFamilyName(url: string): string +{ + const ext = extname(url); + const name = basename(url, ext); + + // Replace dashes by white spaces + const nameWithSpaces = name.replace(/(-|_)/g, ' '); + + // Upper case first character of each word + const nameTitleCase = nameWithSpaces.toLowerCase() + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + return nameTitleCase; +} + +/** Web font loader plugin */ +export const loadWebFont = { + extension: ExtensionType.LoadParser, + + test(url: string): boolean + { + const tempURL = url.split('?')[0]; + const extension = tempURL.split('.').pop(); + + return validFonts.includes(extension); + }, + + async load(url: string, options?: LoadAsset): Promise + { + // Prevent loading font if navigator is not online + if (!window.navigator.onLine) + { + throw new Error('[loadWebFont] Cannot load font - navigator is offline'); + } + + if ('FontFace' in window) + { + const fontFaces: FontFace[] = []; + const name = options.data?.family ?? getFontFamilyName(url); + const weights = options.data?.weights?.filter((weight) => validWeights.includes(weight)) ?? ['normal']; + const data = options.data ?? {}; + + for (let i = 0; i < weights.length; i++) + { + const weight = weights[i]; + + const font = new FontFace(name, `url(${url})`, { + ...data, + weight, + }); + + await font.load(); + + document.fonts.add(font); + + fontFaces.push(font); + } + + return fontFaces.length === 1 ? fontFaces[0] : fontFaces; + } + + // #if _DEBUG + console.warn('[loadWebFont] FontFace API is not supported. Skipping loading font'); + // #endif + + return null; + }, + + unload(font: FontFace | FontFace[]): void + { + (Array.isArray(font) ? font : [font]) + .forEach((t) => document.fonts.delete(t)); + } +} as LoaderParser; diff --git a/packages/assets/src/loader/types.ts b/packages/assets/src/loader/types.ts new file mode 100644 index 0000000000..f9d1166aa1 --- /dev/null +++ b/packages/assets/src/loader/types.ts @@ -0,0 +1,13 @@ +import type { LoaderParser } from './parsers'; + +export interface LoadAsset +{ + src: string; + data?: T; +} + +export interface PromiseAndParser +{ + promise: Promise + parser: LoaderParser +} diff --git a/packages/assets/src/resolver/Resolver.ts b/packages/assets/src/resolver/Resolver.ts new file mode 100644 index 0000000000..85bd4cdeea --- /dev/null +++ b/packages/assets/src/resolver/Resolver.ts @@ -0,0 +1,542 @@ +import { convertToList } from '../utils/convertToList'; +import { createStringVariations } from '../utils/createStringVariations'; +import { isSingleItem } from '../utils/isSingleItem'; +import { getBaseUrl, makeAbsoluteUrl } from '../utils/url/makeAbsoluteUrl'; +import type { ResolveAsset, PreferOrder, ResolveURLParser, ResolverManifest, ResolverBundle } from './types'; + +/** + * A class that is responsible for resolving mapping asset URLs to keys. + * At its most basic it can be used for Aliases: + * + * ``` + * resolver.add('foo', 'bar'); + * resolver.resolveUrl('foo') // => 'bar' + * ``` + * + * It can also be used to resolve the most appropriate asset for a given URL: + * + * ``` + * resolver.prefer({ + * params:{ + * format:'webp', + * resolution: 2, + * } + * }) + * + * resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']); + * + * resolver.resolveUrl('foo') // => 'bar@2x.webp' + * ``` + * Other features include: + * - Ability to process a manifest file to get the correct understanding of how to resolve all assets + * - Ability to add custom parsers for specific file types + * - Ability to add custom prefer rules + * + * This class only cares about the URL, not the loading of the asset itself. + * + * It is not intended that this class is created by developers - its part of the Asset class + * This is the third major system of PixiJS' main Assets class + * @memberof PIXI + */ +export class Resolver +{ + private _assetMap: Record = {}; + private _preferredOrder: PreferOrder[] = []; + + private _parsers: ResolveURLParser[] = []; + + private _resolverHash: Record = {}; + private _basePath: string; + private _manifest: ResolverManifest; + private _bundles: Record = {}; + + /** + * Let the resolver know which assets you prefer to use when resolving assets. + * Multiple prefer user defined rules can be added. + * @example + * resolver.prefer({ + * // first look for something with the correct format, and then then correct resolution + * priority: ['format', 'resolution'], + * params:{ + * format:'webp', // prefer webp images + * resolution: 2, // prefer a resolution of 2 + * } + * }) + * resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']); + * resolver.resolveUrl('foo') // => 'bar@2x.webp' + * @param preferOrders - the prefer options + */ + public prefer(...preferOrders: PreferOrder[]): void + { + preferOrders.forEach((prefer) => + { + this._preferredOrder.push(prefer); + + if (!prefer.priority) + { + // generate the priority based on the order of the object + prefer.priority = Object.keys(prefer.params); + } + }); + + this._resolverHash = {}; + } + + /** + * Set the base path to append to all urls when resolving + * @example + * resolver.basePath = 'https://home.com/'; + * resolver.add('foo', 'bar.ong'); + * resolver.resolveUrl('foo', 'bar.png'); // => 'https://home.com/bar.png' + * @param basePath - the base path to use + */ + public set basePath(basePath: string) + { + this._basePath = getBaseUrl(basePath); + } + + public get basePath(): string + { + return this._basePath; + } + + /** Used for testing, this resets the resolver to its initial state */ + public reset(): void + { + this._preferredOrder = []; + + this._resolverHash = {}; + this._assetMap = {}; + this._basePath = null; + this._manifest = null; + } + + /** + * A URL parser helps the parser to extract information and create an asset object-based on parsing the URL itself. + * @example + * resolver.add('foo', [ + * { + * resolution:2, + * format:'png' + * src: 'image@2x.png' + * }, + * { + * resolution:1, + * format:'png' + * src: 'image.png' + * } + * ]); + * + * + * // with a url parser the information such as resolution and file format could extracted from the url itself: + * resolver.addUrlParser({ + * test: loadTextures.test, // test if url ends in an image + * parse: (value: string) => + * ({ + * resolution: parseFloat(settings.RETINA_PREFIX.exec(value)?.[1] ?? '1'), + * format: value.split('.').pop(), + * src: value, + * }), + * }); + * + * // now resolution and format can be extracted from the url + * resolver.add('foo', [ + * 'image@2x.png' + * 'image.png' + * ]); + * @param urlParsers - The URL parser that you want to add to the resolver + */ + public addUrlParser(...urlParsers: ResolveURLParser[]): void + { + urlParsers.forEach((parser) => + { + if (this._parsers.includes(parser)) return; + + this._parsers.push(parser); + }); + } + + /** + * Remove a URL parser from the resolver + * @param urlParsers - the URL parser that you want to remove from the resolver + */ + public removeUrlParser(...urlParsers: ResolveURLParser[]): void + { + for (let i = urlParsers.length - 1; i >= 0; i--) + { + const parser = urlParsers[i]; + const index = this._parsers.indexOf(parser); + + if (index !== -1) + { + this._parsers.splice(index, 1); + } + } + } + + /** + * Add a manifest to the asset resolver. This is a nice way to add all the asset information in one go. + * generally a manifest would be built using a tool. + * @param manifest - the manifest to add to the resolver + */ + public addManifest(manifest: ResolverManifest): void + { + if (this._manifest) + { + // #if _DEBUG + console.warn('[Resolver] Manifest already exists, this will be overwritten'); + // #endif + } + + this._manifest = manifest; + + manifest.bundles.forEach((bundle) => + { + this.addBundle(bundle.name, bundle.assets); + }); + } + + /** + * This adds a bundle of assets in one go so that you can resolve them as a group. + * For example you could add a bundle for each screen in you pixi app + * @example + * resolver.addBundle('animals', { + * bunny: 'bunny.png', + * chicken: 'chicken.png', + * thumper: 'thumper.png', + * }); + * + * const resolvedAssets = await resolver.resolveBundle('animals'); + * @param bundleId - The id of the bundle to add + * @param assets - A record of the asset or assets that will be chosen from when loading via the specified key + */ + public addBundle(bundleId: string, assets: ResolverBundle['assets']): void + { + const assetNames: string[] = []; + + if (Array.isArray(assets)) + { + assets.forEach((asset) => + { + if (typeof asset.name === 'string') + { + assetNames.push(asset.name); + } + else + { + assetNames.push(...asset.name); + } + + this.add(asset.name, asset.srcs); + }); + } + else + { + Object.keys(assets).forEach((key) => + { + assetNames.push(key); + this.add(key, assets[key]); + }); + } + + this._bundles[bundleId] = assetNames; + } + + /** + * Tells the resolver what keys are associated with witch asset. + * The most important thing the resolver does + * @example + * // single key, single asset: + * resolver.add('foo', 'bar.png'); + * resolver.resolveUrl('foo') // => 'bar.png' + * + * // multiple keys, single asset: + * resolver.add(['foo', 'boo'], 'bar.png'); + * resolver.resolveUrl('foo') // => 'bar.png' + * resolver.resolveUrl('boo') // => 'bar.png' + * + * // multiple keys, multiple assets: + * resolver.add(['foo', 'boo'], ['bar.png', 'bar.webp']); + * resolver.resolveUrl('foo') // => 'bar.png' + * + * // add custom data attached to the resolver + * Resolver.add( + * 'bunnyBooBooSmooth', + * 'bunny{png,webp}', + * {scaleMode:SCALE_MODES.NEAREST} // base texture options + * ); + * + * resolver.resolve('bunnyBooBooSmooth') // => {src: 'bunny.png', data: {scaleMode: SCALE_MODES.NEAREST}} + * @param keysIn - The keys to map, can be an array or a single key + * @param assetsIn - The assets to associate with the key(s) + * @param data - The data that will be attached to the object that resolved object. + */ + public add(keysIn: string | string[], assetsIn: string | ResolveAsset | (ResolveAsset | string)[], data?: unknown): void + { + const keys: string[] = convertToList(keysIn); + + keys.forEach((key) => + { + if (this._assetMap[key]) + { + // #if _DEBUG + console.warn(`[Resolver] already has key: ${key} overwriting`); + // #endif + } + }); + + if (!Array.isArray(assetsIn)) + { + if (typeof assetsIn === 'string') + { + assetsIn = createStringVariations(assetsIn); + } + else + { + assetsIn = [assetsIn]; + } + } + + const assetMap: ResolveAsset[] = assetsIn.map((asset): ResolveAsset => + { + let formattedAsset = asset as ResolveAsset; + + // check if is a string + if (typeof asset === 'string') + { + // first see if it contains any {} tags... + + let parsed = false; + + for (let i = 0; i < this._parsers.length; i++) + { + const parser = this._parsers[i]; + + if (parser.test(asset)) + { + formattedAsset = parser.parse(asset); + parsed = true; + break; + } + } + + if (!parsed) + { + formattedAsset = { + src: asset, + }; + } + } + + if (!formattedAsset.format) + { + formattedAsset.format = formattedAsset.src.split('.').pop(); + } + + if (!formattedAsset.alias) + { + formattedAsset.alias = keys; + } + + if (this._basePath) + { + formattedAsset.src = makeAbsoluteUrl(formattedAsset.src, this._basePath); + } + + formattedAsset.data = formattedAsset.data ?? data; + + return formattedAsset; + }); + + keys.forEach((key) => + { + this._assetMap[key] = assetMap; + }); + } + + /** + * If the resolver has had a manifest set via setManifest, this will return the assets urls for + * a given bundleId or bundleIds. + * @example + * // manifest example + * const manifest = { + * bundles:[{ + * name:'load-screen', + * assets:[ + * { + * name: 'background', + * srcs: 'sunset.png', + * }, + * { + * name: 'bar', + * srcs: 'load-bar.{png,webp}', + * } + * ] + * }, + * { + * name:'game-screen', + * assets:[ + * { + * name: 'character', + * srcs: 'robot.png', + * }, + * { + * name: 'enemy', + * srcs: 'bad-guy.png', + * } + * ] + * }] + * }} + * resolver.setManifest(manifest); + * const resolved = resolver.resolveBundle('load-screen'); + * @param bundleIds - The bundle ids to resolve + * @returns All the bundles assets or a hash of assets for each bundle specified + */ + public resolveBundle(bundleIds: string | string[]): + Record | Record> + { + const singleAsset = isSingleItem(bundleIds); + + bundleIds = convertToList(bundleIds); + + const out: Record> = {}; + + bundleIds.forEach((bundleId) => + { + const assetNames = this._bundles[bundleId]; + + if (assetNames) + { + out[bundleId] = this.resolve(assetNames) as Record; + } + }); + + return singleAsset ? out[bundleIds[0]] : out; + } + + /** + * Does exactly what resolve does, but returns just the URL rather than the whole asset object + * @param key - The key or keys to resolve + * @returns - The URLs associated with the key(s) + */ + public resolveUrl(key: string | string[]): string | Record + { + const result = this.resolve(key); + + if (typeof key !== 'string') + { + const out: Record = {}; + + for (const i in result) + { + out[i] = (result as Record)[i].src; + } + + return out; + } + + return (result as ResolveAsset).src; + } + + /** + * Resolves each key in the list to an asset object. + * Another key function of the resolver! After adding all the various key/asset pairs. this will run the logic + * of finding which asset to return based on any preferences set using the `prefer` function + * by default the same key passed in will be returned if nothing is matched by the resolver. + * @example + * resolver.add('boo', 'bunny.png'); + * + * resolver.resolve('boo') // => {src:'bunny.png'} + * + * // will return the same string as no key was added for this value.. + * resolver.resolve('another-thing.png') // => {src:'another-thing.png'} + * @param keys - key or keys to resolve + * @returns - the resolve asset or a hash of resolve assets for each key specified + */ + public resolve(keys: string | string[]): ResolveAsset | Record + { + const singleAsset = isSingleItem(keys); + + keys = convertToList(keys); + + const result: Record = {}; + + keys.forEach((key) => + { + if (!this._resolverHash[key]) + { + if (this._assetMap[key]) + { + let assets = this._assetMap[key]; + + const preferredOrder = this._getPreferredOrder(assets); + + const bestAsset = assets[0]; + + preferredOrder?.priority.forEach((priorityKey) => + { + preferredOrder.params[priorityKey].forEach((value: unknown) => + { + const filteredAssets = assets.filter((asset) => + { + if (asset[priorityKey]) + { + return asset[priorityKey] === value; + } + + return false; + }); + + if (filteredAssets.length) + { + assets = filteredAssets; + } + }); + }); + + this._resolverHash[key] = (assets[0] ?? bestAsset); + } + else + { + let src = key; + + if (this._basePath) + { + src = makeAbsoluteUrl(src, this._basePath); + } + + // if the resolver fails we just pass back the key assuming its a url + this._resolverHash[key] = { + src, + }; + } + } + + result[key] = this._resolverHash[key]; + }); + + return singleAsset ? result[keys[0]] : result; + } + + /** + * Internal function for figuring out what prefer criteria an asset should use. + * @param assets + */ + private _getPreferredOrder(assets: ResolveAsset[]): PreferOrder + { + for (let i = 0; i < assets.length; i++) + { + const asset = assets[0]; + + const preferred = this._preferredOrder.find((preference: PreferOrder) => + preference.params.format.includes(asset.format)); + + if (preferred) + { + return preferred; + } + } + + return this._preferredOrder[0]; + } +} diff --git a/packages/assets/src/resolver/index.ts b/packages/assets/src/resolver/index.ts new file mode 100644 index 0000000000..78f56d4593 --- /dev/null +++ b/packages/assets/src/resolver/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './parsers'; diff --git a/packages/assets/src/resolver/parsers/index.ts b/packages/assets/src/resolver/parsers/index.ts new file mode 100644 index 0000000000..59c17b3cc5 --- /dev/null +++ b/packages/assets/src/resolver/parsers/index.ts @@ -0,0 +1,2 @@ +export * from './spriteSheetUrlParser'; +export * from './textureUrlParser'; diff --git a/packages/assets/src/resolver/parsers/spriteSheetUrlParser.ts b/packages/assets/src/resolver/parsers/spriteSheetUrlParser.ts new file mode 100644 index 0000000000..0530a0f065 --- /dev/null +++ b/packages/assets/src/resolver/parsers/spriteSheetUrlParser.ts @@ -0,0 +1,31 @@ +import { ExtensionType } from '@pixi/core'; +import { settings } from '@pixi/settings'; +import type { ResolveAsset, ResolveURLParser } from '../types'; + +const validImages = ['jpg', 'png', 'jpeg', 'avif', 'webp']; + +export const spriteSheetUrlParser = { + extension: ExtensionType.ResolveParser, + + test: (value: string): boolean => + { + const tempURL = value.split('?')[0]; + + const split = tempURL.split('.'); + + const extension = split.pop(); + const format = split.pop(); + + return extension === 'json' && validImages.includes(format); + }, + parse: (value: string): ResolveAsset => + { + const split = value.split('.'); + + return { + resolution: parseFloat(settings.RETINA_PREFIX.exec(value)?.[1] ?? '1'), + format: split[split.length - 2], + src: value, + }; + }, +} as ResolveURLParser; diff --git a/packages/assets/src/resolver/parsers/textureUrlParser.ts b/packages/assets/src/resolver/parsers/textureUrlParser.ts new file mode 100644 index 0000000000..17007ecaca --- /dev/null +++ b/packages/assets/src/resolver/parsers/textureUrlParser.ts @@ -0,0 +1,16 @@ +import { ExtensionType } from '@pixi/core'; +import { settings } from '@pixi/settings'; + +import { loadTextures } from '../../loader'; +import type { ResolveAsset, ResolveURLParser } from '../types'; + +export const textureUrlParser = { + extension: ExtensionType.ResolveParser, + test: loadTextures.test, + parse: (value: string): ResolveAsset => + ({ + resolution: parseFloat(settings.RETINA_PREFIX.exec(value)?.[1] ?? '1'), + format: value.split('.').pop(), + src: value, + }), +} as ResolveURLParser; diff --git a/packages/assets/src/resolver/types.ts b/packages/assets/src/resolver/types.ts new file mode 100644 index 0000000000..4c6bd43e21 --- /dev/null +++ b/packages/assets/src/resolver/types.ts @@ -0,0 +1,66 @@ +import type { ExtensionMetadata } from '@pixi/core'; + +/** + * A prefer order lets the resolver know which assets to prefere depending on the various parameters passed to it. + * @memberof PIXI + */ +export interface PreferOrder +{ + /** the importance order of the params */ + priority?: string[]; + params: { + [key: string]: any; + }; +} + +/** + * the object returned when a key is resolved to an asset. + * it will contain any additional information passed in the asset was added. + * @memberof PIXI + */ +export interface ResolveAsset extends Record +{ + alias?: string[]; + src: string; +} + +export type ResolverAssetsArray = { + name: string | string[]; + srcs: string | ResolveAsset[]; +}[]; + +export type ResolverAssetsObject = Record; + +/** + * Structure of a bundle found in a manfest file + * @memberof PIXI + */ +export interface ResolverBundle +{ + name: string; + assets: ResolverAssetsArray | ResolverAssetsObject +} + +/** + * The expected format of a manifest. This would normally be auto generated ar made by the developer + * @memberof PIXI + */ +export type ResolverManifest = { + // room for more props as we go! + bundles: ResolverBundle[]; +}; + +/** + * Format for url parser, will test a string and if it pass will then parse it, turning it into an ResolveAsset + * @memberof PIXI + */ +export interface ResolveURLParser +{ + extension?: ExtensionMetadata; + /** A config to adjust the parser */ + config?: Record + /** the test to perform on the url to determin if it should be parsed */ + test: (url: string) => boolean; + /** the function that will convert the url into an object */ + parse: (value: string) => ResolveAsset; +} diff --git a/packages/assets/src/utils/convertToList.ts b/packages/assets/src/utils/convertToList.ts new file mode 100644 index 0000000000..8154d76b6c --- /dev/null +++ b/packages/assets/src/utils/convertToList.ts @@ -0,0 +1,22 @@ +export const convertToList = (input: string | T | (string | T)[], transform?: (input: string) => T): T[] => +{ + if (!Array.isArray(input)) + { + input = [input as T]; + } + + if (!transform) + { + return input as T[]; + } + + return (input as (string | T)[]).map((item): T => + { + if (typeof item === 'string') + { + return transform(item as string); + } + + return item as T; + }); +}; diff --git a/packages/assets/src/utils/createStringVariations.ts b/packages/assets/src/utils/createStringVariations.ts new file mode 100644 index 0000000000..1f2c2d9658 --- /dev/null +++ b/packages/assets/src/utils/createStringVariations.ts @@ -0,0 +1,55 @@ +function processX(base: string, ids: string[][], depth: number, result: string[], tags: string[]) +{ + const id = ids[depth]; + + for (let i = 0; i < id.length; i++) + { + const value = id[i]; + + if (depth < ids.length - 1) + { + processX(base.replace(result[depth], value), ids, depth + 1, result, tags); + } + else + { + tags.push(base.replace(result[depth], value)); + } + } +} + +/** + * Creates a list of all possible combinations of the given strings. + * @example + * const out2 = createStringVariations('name is {chicken,wolf,sheep}'); + * console.log(out2); // [ 'name is chicken', 'name is wolf', 'name is sheep' ] + * @param string - The string to process + */ +export function createStringVariations(string: string): string[] +{ + const regex = /\{(.*?)\}/g; + + const result = string.match(regex); + + const tags: string[] = []; + + if (result) + { + const ids: string[][] = []; + + result.forEach((vars) => + { + // first remove the brackets... + const split = vars.substring(1, vars.length - 1).split(','); + + ids.push(split); + }); + + processX(string, ids, 0, result, tags); + } + else + { + tags.push(string); + } + + return tags; +} diff --git a/packages/assets/src/utils/detections/detectAvif.ts b/packages/assets/src/utils/detections/detectAvif.ts new file mode 100644 index 0000000000..627e6c8875 --- /dev/null +++ b/packages/assets/src/utils/detections/detectAvif.ts @@ -0,0 +1,10 @@ +export async function detectAvif(): Promise +{ + if (!globalThis.createImageBitmap) return false; + + // eslint-disable-next-line max-len + const avifData = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A='; + const blob = await fetch(avifData).then((r) => r.blob()); + + return createImageBitmap(blob).then(() => true, () => false); +} diff --git a/packages/assets/src/utils/detections/detectWebp.ts b/packages/assets/src/utils/detections/detectWebp.ts new file mode 100644 index 0000000000..c52452dbb4 --- /dev/null +++ b/packages/assets/src/utils/detections/detectWebp.ts @@ -0,0 +1,9 @@ +export async function detectWebp(): Promise +{ + if (!globalThis.createImageBitmap) return false; + + const webpData = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA='; + const blob = await fetch(webpData).then((r) => r.blob()); + + return createImageBitmap(blob).then(() => true, () => false); +} diff --git a/packages/assets/src/utils/detections/index.ts b/packages/assets/src/utils/detections/index.ts new file mode 100644 index 0000000000..9090a8684a --- /dev/null +++ b/packages/assets/src/utils/detections/index.ts @@ -0,0 +1,2 @@ +export * from './detectAvif'; +export * from './detectWebp'; diff --git a/packages/assets/src/utils/index.ts b/packages/assets/src/utils/index.ts new file mode 100644 index 0000000000..99cb83b954 --- /dev/null +++ b/packages/assets/src/utils/index.ts @@ -0,0 +1,6 @@ +export * from './path'; +export * from './detections'; +export * from './url'; +export * from './convertToList'; +export * from './createStringVariations'; +export * from './isSingleItem'; diff --git a/packages/assets/src/utils/isSingleItem.ts b/packages/assets/src/utils/isSingleItem.ts new file mode 100644 index 0000000000..2c6c4fcdf2 --- /dev/null +++ b/packages/assets/src/utils/isSingleItem.ts @@ -0,0 +1,5 @@ +/** + * Checks if the given value is an array. + * @param item - The item to test + */ +export const isSingleItem = (item: unknown): boolean => (!Array.isArray(item)); diff --git a/packages/assets/src/utils/path.ts b/packages/assets/src/utils/path.ts new file mode 100644 index 0000000000..064cc2b5ad --- /dev/null +++ b/packages/assets/src/utils/path.ts @@ -0,0 +1,151 @@ +function isAbsolute(path: string): boolean +{ + return path.charAt(0) === '/'; +} + +// Split a filename into [root, dir, basename, ext], unix version +// 'root' is just a slash, or nothing. +const splitPathRe + = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + +function splitPath(filename: string): string[] +{ + return splitPathRe.exec(filename).slice(1); +} + +/** + * Returns the last portion of a path + * @param path - The path to get the last portion of + * @param ext - An optional extension to add to the end of the path + */ +export function basename(path: string, ext: string): string +{ + let f = splitPath(path)[2]; + + if (ext && f.substring(-1 * ext.length) === ext) + { + f = f.substring(0, f.length - ext.length); + } + + return f; +} + +/** + * Returns the extension of the path, from the last occurrence of the . (period) character + * to end of the string in the last portion of the path + * @param path - The path to get the extension of + */ +export function extname(path: string): string +{ + return splitPath(path)[3]; +} + +/** + * Returns the extension of the path, from the last occurrence of the . (period) character to the end of + * string in the last portion of the path. + * @param parts - The path parts to join + */ +export function join(...parts: string[]): string +{ + let path = ''; + + for (let i = 0; i < parts.length; i++) + { + const segment = parts[i]; + + if (segment) + { + if (!path) + { + path += segment; + } + else + { + path += `/${segment}`; + } + } + } + + return normalize(path); +} + +/** + * Returns the directory name of a path + * @param path - The path to resolve. + */ +export function dirname(path: string): string +{ + const result = splitPath(path); + const root = result[0]; + let dir = result[1]; + + if (!root && !dir) + { + // No dirname whatsoever + return '.'; + } + + if (dir) + { + // It has a dirname, strip trailing slash + dir = dir.substring(0, dir.length - 1); + } + + return root + dir; +} + +function normalize(path: string): string +{ + const _isAbsolute = isAbsolute(path); + const trailingSlash = path && path[path.length - 1] === '/'; + + // Normalize the path + path = normalizeArray(path.split('/'), !_isAbsolute).join('/'); + + if (!path && !_isAbsolute) + { + path = '.'; + } + if (path && trailingSlash) + { + path += '/'; + } + + return (_isAbsolute ? '/' : '') + path; +} + +// resolves . and .. elements in a path array with directory names there +// must be no slashes or device names (c:\) in the array +// (so also no leading and trailing slashes - it does not distinguish +// relative and absolute paths) +function normalizeArray(parts: string[], allowAboveRoot: boolean): string[] +{ + const res = []; + + for (let i = 0; i < parts.length; i++) + { + const p = parts[i]; + + // ignore empty parts + if (!p || p === '.') + { continue; } + + if (p === '..') + { + if (res.length && res[res.length - 1] !== '..') + { + res.pop(); + } + else if (allowAboveRoot) + { + res.push('..'); + } + } + else + { + res.push(p); + } + } + + return res; +} diff --git a/packages/assets/src/utils/url/index.ts b/packages/assets/src/utils/url/index.ts new file mode 100644 index 0000000000..60e4cd4515 --- /dev/null +++ b/packages/assets/src/utils/url/index.ts @@ -0,0 +1,3 @@ +export * from './urlJoin'; +export * from './isAbsoluteUrl'; +export * from './makeAbsoluteUrl'; diff --git a/packages/assets/src/utils/url/isAbsoluteUrl.ts b/packages/assets/src/utils/url/isAbsoluteUrl.ts new file mode 100644 index 0000000000..7ab0b2f118 --- /dev/null +++ b/packages/assets/src/utils/url/isAbsoluteUrl.ts @@ -0,0 +1,21 @@ +/** + * Used to check whether the given URL is an absolute URL or not. + * An absolute URL is defined as a URL that contains the complete details needed to locate a file + * + * Taken directly from here: https://github.com/sindresorhus/is-absolute-url/blob/master/index.js + * + * returns true if the URL is absolute, false if relative. + * @param url - The URL to test + */ +export function isAbsoluteUrl(url: string): boolean +{ + // Don't match Windows paths `c:\` + if ((/^[a-zA-Z]:\\/).test(url)) + { + return false; + } + + // Scheme: https://tools.ietf.org/html/rfc3986#section-3.1 + // Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3 + return (/^[a-zA-Z][a-zA-Z\d+\-.]*:/).test(url); +} diff --git a/packages/assets/src/utils/url/makeAbsoluteUrl.ts b/packages/assets/src/utils/url/makeAbsoluteUrl.ts new file mode 100644 index 0000000000..380e800d3e --- /dev/null +++ b/packages/assets/src/utils/url/makeAbsoluteUrl.ts @@ -0,0 +1,27 @@ +import { isAbsoluteUrl } from './isAbsoluteUrl'; +import { urlJoin } from './urlJoin'; + +export function getBaseUrl(url: string): string +{ + const re = new RegExp(/^.*\//); + + return re.exec(url.split('?')[0])[0].replace(new RegExp(/#\/|#/), ''); +} + +const baseUrl = getBaseUrl(document.baseURI ?? window.location.href); + +/** + * Converts URL to an absolute path. + * When loading from a Web Worker, we must use absolute paths. + * If the URL is already absolute we return it as is + * If it's not, we convert it + * @param url - The URL to test + * @param customBaseUrl - The base URL to use + */ +export function makeAbsoluteUrl(url: string, customBaseUrl?: string): string +{ + const base = customBaseUrl !== undefined ? getBaseUrl(customBaseUrl) : baseUrl; + const absolutePath = isAbsoluteUrl(url) ? url : urlJoin(base, url); + + return absolutePath; +} diff --git a/packages/assets/src/utils/url/urlJoin.ts b/packages/assets/src/utils/url/urlJoin.ts new file mode 100644 index 0000000000..f9db50f027 --- /dev/null +++ b/packages/assets/src/utils/url/urlJoin.ts @@ -0,0 +1,89 @@ +function normalize(strArray: string[]): string +{ + const resultArray = []; + + if (strArray.length === 0) { return ''; } + + if (typeof strArray[0] !== 'string') + { + throw new TypeError(`Url must be a string. Received ${strArray[0]}`); + } + + // If the first part is a plain protocol, we combine it with the next part. + if (((/^[^/:]+:\/*$/).exec(strArray[0])) && strArray.length > 1) + { + const first = strArray.shift(); + + strArray[0] = first + strArray[0]; + } + + // There must be two or three slashes in the file protocol, two slashes in anything else. + if ((/^file:\/\/\//).exec(strArray[0])) + { + strArray[0] = strArray[0].replace(/^([^/:]+):\/*/, '$1:///'); + } + else + { + strArray[0] = strArray[0].replace(/^([^/:]+):\/*/, '$1://'); + } + + for (let i = 0; i < strArray.length; i++) + { + let component = strArray[i]; + + if (typeof component !== 'string') + { + throw new TypeError(`Url must be a string. Received ${component}`); + } + + if (component === '') { continue; } + + if (i > 0) + { + // Removing the starting slashes for each component but the first. + component = component.replace(/^[/]+/, ''); + } + if (i < strArray.length - 1) + { + // Removing the ending slashes for each component but the last. + component = component.replace(/[/]+$/, ''); + } + else + { + // For the last component we will combine multiple slashes to a single one. + component = component.replace(/[/]+$/, '/'); + } + + resultArray.push(component); + } + + let str = resultArray.join('/'); + // Each input component is now separated by a single slash except the possible first plain protocol part. + + // remove trailing slash before parameters or hash + str = str.replace(/\/(\?|&|#[^!])/g, '$1'); + + // replace ? in parameters with & + const parts = str.split('?'); + + str = parts.shift() + (parts.length > 0 ? '?' : '') + parts.join('&'); + + return str; +} + +export function urlJoin(...arg: string[]): string +{ + let input; + + if (typeof arg[0] === 'object') + { + input = arg[0]; + } + else + { + input = [].slice.call(arg); + } + + return normalize(input); +} + diff --git a/packages/assets/test/assets.tests.ts b/packages/assets/test/assets.tests.ts new file mode 100644 index 0000000000..bfed561094 --- /dev/null +++ b/packages/assets/test/assets.tests.ts @@ -0,0 +1,337 @@ +import { BaseTexture, Texture } from '@pixi/core'; +import { Spritesheet } from '@pixi/spritesheet'; + +import { Assets } from '@pixi/assets'; + +function wait(value = 500) +{ + // wait a bit... + return new Promise((resolve) => + setTimeout(() => resolve(), value)); +} + +describe('Assets', () => +{ + const basePath = process.env.GITHUB_ACTIONS + ? `https://raw.githubusercontent.com/pixijs/pixijs/${process.env.GITHUB_SHA}/packages/assets/test/assets/` + : 'http://localhost:8080/assets/test/assets/'; + + beforeEach(() => + { + // reset the loader + Assets.reset(); + }); + + it('should load assets', async () => + { + await Assets.init({ + basePath, + }); + + const bunny = await Assets.load('textures/bunny.png'); + + expect(bunny).toBeInstanceOf(Texture); + }); + + it('should get assets once loaded', async () => + { + await Assets.init({ + basePath, + }); + + Assets.add('test', 'textures/bunny.png'); + + // not loaded yet! + const bunny0 = Assets.get('test'); + + expect(bunny0).toBe(undefined); + + const bunny = await Assets.load('test'); + + const bunny2 = Assets.get('test'); + + expect(bunny).toBeInstanceOf(Texture); + expect(bunny2).toBe(bunny); + }); + + it('should load a webp if available by default', async () => + { + await Assets.init({ + basePath, + texturePreference: { + resolution: 2, + }, + }); + + Assets.add('test', [ + 'textures/profile-abel@0.5x.jpg', + 'textures/profile-abel@2x.jpg', + 'textures/profile-abel@0.5x.webp', + 'textures/profile-abel@2x.webp', + ]); + + // not loaded yet! + const bunny = await Assets.load('test'); + + expect(bunny.baseTexture.resource.src).toBe(`${basePath}textures/profile-abel@2x.webp`); + }); + + it('should load a correct texture based on preference', async () => + { + await Assets.init({ + basePath, + texturePreference: { + format: 'jpg', + resolution: 2, + }, + }); + + Assets.add('test', [ + 'textures/profile-abel@0.5x.jpg', + 'textures/profile-abel@2x.jpg', + 'textures/profile-abel@0.5x.webp', + 'textures/profile-abel@2x.webp', + ]); + + // not loaded yet! + const bunny = await Assets.load('test'); + + expect(bunny.baseTexture.resource.src).toBe(`${basePath}textures/profile-abel@2x.jpg`); + }); + + it('should add and load bundle', async () => + { + await Assets.init({ + basePath, + }); + + Assets.addBundle('testBundle', { + bunny: 'textures/bunny.{png,webp}', + spritesheet: 'spritesheet/spritesheet.json', + }); + + const assets = await Assets.loadBundle('testBundle'); + + expect(assets.bunny).toBeInstanceOf(Texture); + expect(assets.spritesheet).toBeInstanceOf(Spritesheet); + }); + + it('should load a bundle found in the manifest', async () => + { + await Assets.init({ + basePath, + manifest: 'json/asset-manifest-2.json', + }); + + const assets = await Assets.loadBundle('default'); + + expect(assets.bunny).toBeInstanceOf(Texture); + expect(assets['profile-abel']).toBeInstanceOf(Texture); + expect(assets.spritesheet).toBeInstanceOf(Spritesheet); + }); + + it('should load multiple bundles', async () => + { + await Assets.init({ + basePath, + manifest: 'json/asset-manifest-2.json', + }); + + const assets = await Assets.loadBundle(['default', 'data']); + + expect(assets.default.bunny).toBeInstanceOf(Texture); + expect(assets.default['profile-abel']).toBeInstanceOf(Texture); + expect(assets.default.spritesheet).toBeInstanceOf(Spritesheet); + + expect(assets.data[`test.json`]).toEqual({ testNumber: 23, testString: 'Test String 23' }); + }); + + it('should map all names', async () => + { + Assets.init({ + basePath, + }); + + Assets.add(['fish', 'chicken'], 'textures/bunny.png'); + + const bunny = await Assets.load('fish'); + + // this should be the same as bunny + const bunny2 = await Assets.get('chicken'); + + expect(bunny).toBeInstanceOf(Texture); + expect(bunny).toBe(bunny2); + }); + + it('should split url versions correctly', async () => + { + await Assets.init({ + basePath, + }); + + Assets.add('fish', 'textures/bunny.{png,webp}'); + + const bunny = await Assets.load('fish'); + + expect(bunny.baseTexture.resource.src).toBe(`${basePath}textures/bunny.webp`); + }); + + it('should background load correctly', async () => + { + await Assets.init({ + basePath, + }); + + Assets.backgroundLoad(['textures/bunny.png']); + + // wait a bit... + await wait(); + + const asset = await Assets.loader.promiseCache[`${basePath}textures/bunny.png`].promise; + + expect(asset).toBeInstanceOf(Texture); + expect(asset.baseTexture.resource.src).toBe(`${basePath}textures/bunny.png`); + }); + + it('should background load bundles', async () => + { + await Assets.init({ + basePath, + manifest: 'json/asset-manifest-2.json', + }); + + Assets.backgroundLoadBundle('default'); + + // wait a bit... + await wait(); + + const expectTypes = { + 'json/asset-manifest-2.json': Object, + 'textures/bunny.png': Texture, + 'textures/profile-abel@2x.webp': Texture, + 'spritesheet/spritesheet.json': Spritesheet, + 'spritesheet/spritesheet.png': Texture, + }; + + for (const [key, type] of Object.entries(expectTypes)) + { + const asset = await Assets.loader.promiseCache[basePath + key].promise; + + expect(asset).toBeInstanceOf(type); + } + }); + + it('should error out if loader fails', async () => + { + Assets.load('chickenSandwich.png').catch((e) => + { + expect(e).toBeInstanceOf(Error); + }); + }); + + it('should add sprite textures to the cache', async () => + { + await Assets.init({ + basePath, + }); + + await Assets.load('spritesheet/spritesheet.json'); + + const texture = Assets.get('pic-sensei.jpg'); + + expect(texture).toBeInstanceOf(Texture); + }); + + it('should dispose of a texture correctly', async () => + { + await Assets.init({ + basePath, + }); + + const bunny = await Assets.load('textures/bunny.png') as Texture; + + bunny.destroy(true); + + expect(bunny.baseTexture).toBe(null); + + const bunnyReloaded = await Assets.load('textures/bunny.png') as Texture; + + expect(bunnyReloaded.baseTexture).toBeInstanceOf(BaseTexture); + }); + + it('should load texture array correctly', async () => + { + await Assets.init({ + basePath, + }); + + Assets.addBundle('testBundle', { + bunny: 'textures/bunny.{png,webp}', + spritesheet: 'spritesheet/spritesheet.json', + }); + + const assets = await Assets.loadBundle('testBundle'); + + expect(assets.bunny).toBeInstanceOf(Texture); + expect(assets.spritesheet).toBeInstanceOf(Spritesheet); + + await Assets.unloadBundle('testBundle'); + + expect(assets.bunny.baseTexture).toBe(null); + }); + + it('should unload and remove from the cache correctly', async () => + { + await Assets.init({ + basePath, + }); + + Assets.add(['chickenSheet', 'alias'], 'spritesheet/spritesheet.json'); + + await Assets.load('chickenSheet'); + + const texture = Assets.get('pic-sensei.jpg'); + + expect(texture).toBeInstanceOf(Texture); + + await Assets.unload('chickenSheet'); + + const texture2 = Assets.get('pic-sensei.jpg'); + + expect(texture2).toBe(undefined); + }); + + it('should unload assets correctly', async () => + { + await Assets.init({ + basePath, + }); + + const bunny = await Assets.load('textures/bunny.png') as Texture; + + await Assets.unload('textures/bunny.png'); + + expect(bunny.baseTexture).toBe(null); + }); + + it('should unload bundles correctly', async () => + { + await Assets.init({ + basePath, + }); + + Assets.addBundle('testBundle', { + bunny: 'textures/bunny.{png,webp}', + spritesheet: 'spritesheet/spritesheet.json', + }); + + const assets = await Assets.loadBundle('testBundle'); + + expect(assets.bunny).toBeInstanceOf(Texture); + expect(assets.spritesheet).toBeInstanceOf(Spritesheet); + + await Assets.unloadBundle('testBundle'); + + expect(assets.bunny.baseTexture).toBe(null); + }); +}); diff --git a/packages/assets/test/assets/bitmap-font/bmGlyph-test.png b/packages/assets/test/assets/bitmap-font/bmGlyph-test.png new file mode 100644 index 0000000000..793bd79588 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/bmGlyph-test.png differ diff --git a/packages/assets/test/assets/bitmap-font/bmtxt-test.txt b/packages/assets/test/assets/bitmap-font/bmtxt-test.txt new file mode 100644 index 0000000000..285d2eb73d --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/bmtxt-test.txt @@ -0,0 +1,101 @@ +info face="AnisetteStd" size=25 bold=0 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=0,0 +common lineHeight=40 base=36 scaleW=1024 scaleH=512 pages=1 packed=0 +page id=0 file="bmGlyph-test.png" +chars count=95 +char id=32 x=40 y=30 width=0 height=0 xoffset=0 yoffset=27 xadvance=7 page=0 chnl=0 +char id=33 x=87 y=469 width=16 height=28 xoffset=2 yoffset=9 xadvance=8 page=0 chnl=0 +char id=34 x=135 y=370 width=18 height=18 xoffset=3 yoffset=9 xadvance=12 page=0 chnl=0 +char id=35 x=135 y=146 width=26 height=28 xoffset=0 yoffset=9 xadvance=14 page=0 chnl=0 +char id=36 x=87 y=1 width=22 height=32 xoffset=2 yoffset=6 xadvance=14 page=0 chnl=0 +char id=37 x=52 y=450 width=32 height=30 xoffset=1 yoffset=7 xadvance=23 page=0 chnl=0 +char id=38 x=1 y=208 width=38 height=28 xoffset=2 yoffset=9 xadvance=30 page=0 chnl=0 +char id=39 x=36 y=429 width=14 height=18 xoffset=3 yoffset=9 xadvance=7 page=0 chnl=0 +char id=40 x=69 y=175 width=16 height=34 xoffset=2 yoffset=6 xadvance=8 page=0 chnl=0 +char id=41 x=52 y=175 width=16 height=34 xoffset=1 yoffset=6 xadvance=8 page=0 chnl=0 +char id=42 x=135 y=315 width=20 height=20 xoffset=1 yoffset=8 xadvance=10 page=0 chnl=0 +char id=43 x=135 y=292 width=22 height=22 xoffset=2 yoffset=12 xadvance=14 page=0 chnl=0 +char id=44 x=135 y=351 width=16 height=18 xoffset=1 yoffset=23 xadvance=7 page=0 chnl=0 +char id=45 x=135 y=336 width=18 height=14 xoffset=1 yoffset=16 xadvance=7 page=0 chnl=0 +char id=46 x=36 y=458 width=14 height=14 xoffset=2 yoffset=23 xadvance=7 page=0 chnl=0 +char id=47 x=18 y=365 width=22 height=34 xoffset=0 yoffset=5 xadvance=9 page=0 chnl=0 +char id=48 x=135 y=117 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=49 x=87 y=440 width=20 height=28 xoffset=2 yoffset=9 xadvance=14 page=0 chnl=0 +char id=50 x=135 y=88 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=51 x=87 y=411 width=22 height=28 xoffset=2 yoffset=9 xadvance=14 page=0 chnl=0 +char id=52 x=135 y=59 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=53 x=87 y=382 width=22 height=28 xoffset=2 yoffset=9 xadvance=14 page=0 chnl=0 +char id=54 x=110 y=465 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=55 x=110 y=436 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=56 x=110 y=407 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=57 x=110 y=378 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=58 x=36 y=400 width=14 height=22 xoffset=2 yoffset=15 xadvance=7 page=0 chnl=0 +char id=59 x=135 y=175 width=16 height=26 xoffset=1 yoffset=15 xadvance=7 page=0 chnl=0 +char id=60 x=135 y=269 width=22 height=22 xoffset=2 yoffset=12 xadvance=14 page=0 chnl=0 +char id=61 x=135 y=248 width=22 height=20 xoffset=2 yoffset=12 xadvance=14 page=0 chnl=0 +char id=62 x=135 y=225 width=22 height=22 xoffset=2 yoffset=12 xadvance=14 page=0 chnl=0 +char id=63 x=87 y=353 width=20 height=28 xoffset=2 yoffset=9 xadvance=12 page=0 chnl=0 +char id=64 x=1 y=266 width=36 height=28 xoffset=2 yoffset=9 xadvance=27 page=0 chnl=0 +char id=65 x=1 y=179 width=38 height=28 xoffset=1 yoffset=9 xadvance=28 page=0 chnl=0 +char id=66 x=52 y=146 width=34 height=28 xoffset=2 yoffset=9 xadvance=26 page=0 chnl=0 +char id=67 x=52 y=117 width=34 height=28 xoffset=1 yoffset=9 xadvance=25 page=0 chnl=0 +char id=68 x=52 y=88 width=34 height=28 xoffset=2 yoffset=9 xadvance=27 page=0 chnl=0 +char id=69 x=52 y=421 width=32 height=28 xoffset=2 yoffset=9 xadvance=24 page=0 chnl=0 +char id=70 x=52 y=392 width=32 height=28 xoffset=2 yoffset=9 xadvance=23 page=0 chnl=0 +char id=71 x=52 y=59 width=34 height=28 xoffset=1 yoffset=9 xadvance=25 page=0 chnl=0 +char id=72 x=52 y=30 width=34 height=28 xoffset=2 yoffset=9 xadvance=26 page=0 chnl=0 +char id=73 x=87 y=324 width=16 height=28 xoffset=2 yoffset=9 xadvance=9 page=0 chnl=0 +char id=74 x=52 y=359 width=18 height=32 xoffset=-1 yoffset=9 xadvance=9 page=0 chnl=0 +char id=75 x=52 y=1 width=34 height=28 xoffset=2 yoffset=9 xadvance=25 page=0 chnl=0 +char id=76 x=52 y=481 width=30 height=28 xoffset=2 yoffset=9 xadvance=23 page=0 chnl=0 +char id=77 x=1 y=150 width=38 height=28 xoffset=2 yoffset=9 xadvance=30 page=0 chnl=0 +char id=78 x=1 y=458 width=34 height=28 xoffset=2 yoffset=9 xadvance=27 page=0 chnl=0 +char id=79 x=1 y=237 width=36 height=28 xoffset=1 yoffset=9 xadvance=27 page=0 chnl=0 +char id=80 x=52 y=330 width=32 height=28 xoffset=2 yoffset=9 xadvance=25 page=0 chnl=0 +char id=81 x=1 y=117 width=38 height=32 xoffset=1 yoffset=9 xadvance=27 page=0 chnl=0 +char id=82 x=52 y=301 width=32 height=28 xoffset=2 yoffset=9 xadvance=25 page=0 chnl=0 +char id=83 x=52 y=272 width=32 height=28 xoffset=1 yoffset=9 xadvance=24 page=0 chnl=0 +char id=84 x=1 y=429 width=34 height=28 xoffset=1 yoffset=9 xadvance=25 page=0 chnl=0 +char id=85 x=1 y=400 width=34 height=28 xoffset=2 yoffset=9 xadvance=26 page=0 chnl=0 +char id=86 x=1 y=88 width=38 height=28 xoffset=1 yoffset=9 xadvance=28 page=0 chnl=0 +char id=87 x=1 y=1 width=50 height=28 xoffset=1 yoffset=9 xadvance=39 page=0 chnl=0 +char id=88 x=1 y=59 width=38 height=28 xoffset=1 yoffset=9 xadvance=28 page=0 chnl=0 +char id=89 x=1 y=30 width=38 height=28 xoffset=1 yoffset=9 xadvance=28 page=0 chnl=0 +char id=90 x=52 y=243 width=32 height=28 xoffset=1 yoffset=9 xadvance=24 page=0 chnl=0 +char id=91 x=1 y=365 width=16 height=34 xoffset=2 yoffset=5 xadvance=7 page=0 chnl=0 +char id=92 x=20 y=330 width=22 height=34 xoffset=0 yoffset=5 xadvance=9 page=0 chnl=0 +char id=93 x=35 y=295 width=16 height=34 xoffset=1 yoffset=5 xadvance=7 page=0 chnl=0 +char id=94 x=135 y=202 width=22 height=22 xoffset=2 yoffset=8 xadvance=15 page=0 chnl=0 +char id=95 x=1 y=487 width=26 height=14 xoffset=0 yoffset=27 xadvance=14 page=0 chnl=0 +char id=96 x=110 y=494 width=18 height=14 xoffset=5 yoffset=5 xadvance=15 page=0 chnl=0 +char id=97 x=110 y=349 width=24 height=28 xoffset=1 yoffset=9 xadvance=15 page=0 chnl=0 +char id=98 x=110 y=320 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=99 x=87 y=295 width=22 height=28 xoffset=1 yoffset=9 xadvance=12 page=0 chnl=0 +char id=100 x=110 y=291 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=101 x=87 y=266 width=22 height=28 xoffset=2 yoffset=9 xadvance=13 page=0 chnl=0 +char id=102 x=87 y=237 width=20 height=28 xoffset=2 yoffset=9 xadvance=13 page=0 chnl=0 +char id=103 x=87 y=208 width=22 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=104 x=110 y=262 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=105 x=87 y=179 width=16 height=28 xoffset=2 yoffset=9 xadvance=8 page=0 chnl=0 +char id=106 x=87 y=150 width=20 height=28 xoffset=1 yoffset=9 xadvance=11 page=0 chnl=0 +char id=107 x=110 y=233 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=108 x=87 y=121 width=20 height=28 xoffset=2 yoffset=9 xadvance=12 page=0 chnl=0 +char id=109 x=135 y=30 width=26 height=28 xoffset=2 yoffset=9 xadvance=19 page=0 chnl=0 +char id=110 x=110 y=204 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=111 x=110 y=175 width=24 height=28 xoffset=1 yoffset=9 xadvance=15 page=0 chnl=0 +char id=112 x=110 y=146 width=24 height=28 xoffset=2 yoffset=9 xadvance=15 page=0 chnl=0 +char id=113 x=52 y=210 width=24 height=32 xoffset=1 yoffset=9 xadvance=15 page=0 chnl=0 +char id=114 x=110 y=117 width=24 height=28 xoffset=2 yoffset=9 xadvance=15 page=0 chnl=0 +char id=115 x=87 y=92 width=22 height=28 xoffset=1 yoffset=9 xadvance=12 page=0 chnl=0 +char id=116 x=87 y=63 width=22 height=28 xoffset=1 yoffset=9 xadvance=12 page=0 chnl=0 +char id=117 x=110 y=88 width=24 height=28 xoffset=2 yoffset=9 xadvance=16 page=0 chnl=0 +char id=118 x=110 y=59 width=24 height=28 xoffset=1 yoffset=9 xadvance=15 page=0 chnl=0 +char id=119 x=135 y=1 width=28 height=28 xoffset=1 yoffset=9 xadvance=20 page=0 chnl=0 +char id=120 x=110 y=30 width=24 height=28 xoffset=1 yoffset=9 xadvance=15 page=0 chnl=0 +char id=121 x=110 y=1 width=24 height=28 xoffset=1 yoffset=9 xadvance=14 page=0 chnl=0 +char id=122 x=87 y=34 width=22 height=28 xoffset=1 yoffset=9 xadvance=13 page=0 chnl=0 +char id=123 x=1 y=330 width=18 height=34 xoffset=1 yoffset=5 xadvance=8 page=0 chnl=0 +char id=124 x=20 y=295 width=14 height=34 xoffset=3 yoffset=5 xadvance=8 page=0 chnl=0 +char id=125 x=1 y=295 width=18 height=34 xoffset=1 yoffset=5 xadvance=8 page=0 chnl=0 +char id=126 x=28 y=487 width=22 height=16 xoffset=2 yoffset=14 xadvance=14 page=0 chnl=0 +kernings count=1 +kerning first=32 second=32 amount=-2 diff --git a/packages/assets/test/assets/bitmap-font/desyrel.png b/packages/assets/test/assets/bitmap-font/desyrel.png new file mode 100755 index 0000000000..c3559e1c80 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/desyrel.png differ diff --git a/packages/assets/test/assets/bitmap-font/desyrel.xml b/packages/assets/test/assets/bitmap-font/desyrel.xml new file mode 100755 index 0000000000..54fcdbba5c --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/desyrel.xml @@ -0,0 +1,1922 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/test/assets/bitmap-font/font.fnt b/packages/assets/test/assets/bitmap-font/font.fnt new file mode 100644 index 0000000000..56e1060472 --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/font.fnt @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/test/assets/bitmap-font/font.png b/packages/assets/test/assets/bitmap-font/font.png new file mode 100644 index 0000000000..cf772e9df2 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/font.png differ diff --git a/packages/assets/test/assets/bitmap-font/msdf.fnt b/packages/assets/test/assets/bitmap-font/msdf.fnt new file mode 100644 index 0000000000..afe4d39a88 --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/msdf.fnt @@ -0,0 +1,1140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/test/assets/bitmap-font/msdf.png b/packages/assets/test/assets/bitmap-font/msdf.png new file mode 100644 index 0000000000..d1256c7e72 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/msdf.png differ diff --git a/packages/assets/test/assets/bitmap-font/sdf.fnt b/packages/assets/test/assets/bitmap-font/sdf.fnt new file mode 100644 index 0000000000..6d2c6bd49e --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/sdf.fnt @@ -0,0 +1,1140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/test/assets/bitmap-font/sdf.png b/packages/assets/test/assets/bitmap-font/sdf.png new file mode 100644 index 0000000000..7d3f62d31f Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/sdf.png differ diff --git a/packages/assets/test/assets/bitmap-font/split_font.fnt b/packages/assets/test/assets/bitmap-font/split_font.fnt new file mode 100644 index 0000000000..35cbf49ac1 --- /dev/null +++ b/packages/assets/test/assets/bitmap-font/split_font.fnt @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/test/assets/bitmap-font/split_font_ab.png b/packages/assets/test/assets/bitmap-font/split_font_ab.png new file mode 100644 index 0000000000..dca61378d9 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/split_font_ab.png differ diff --git a/packages/assets/test/assets/bitmap-font/split_font_cd.png b/packages/assets/test/assets/bitmap-font/split_font_cd.png new file mode 100644 index 0000000000..8f3c77ec28 Binary files /dev/null and b/packages/assets/test/assets/bitmap-font/split_font_cd.png differ diff --git a/packages/assets/test/assets/fonts/outfit.woff2 b/packages/assets/test/assets/fonts/outfit.woff2 new file mode 100644 index 0000000000..90597af2e8 Binary files /dev/null and b/packages/assets/test/assets/fonts/outfit.woff2 differ diff --git a/packages/assets/test/assets/json/asset-manifest-2.json b/packages/assets/test/assets/json/asset-manifest-2.json new file mode 100644 index 0000000000..2855d9efa5 --- /dev/null +++ b/packages/assets/test/assets/json/asset-manifest-2.json @@ -0,0 +1,52 @@ +{ + "bundles": [ + { + "name": "default", + "assets": [ + { + "name": "bunny", + "srcs": [ + { + "resolution": 1, + "format": "png", + "src": "textures/bunny.png" + } + ] + }, + { + "name": "spritesheet", + "srcs": [ + { + "resolution": 1, + "format": "png", + "src": "spritesheet/spritesheet.json" + }, + { + "resolution": 1, + "format": "png", + "src": "spritesheet/spritesheet.json" + } + ] + }, + { + "name": "profile-abel", + "srcs": [ + "textures/profile-abel@0.5x.jpg", + "textures/profile-abel@2x.webp", + "textures/profile-abel@0.5x.jpg", + "textures/profile-abel@2x.webp" + ] + } + ] + }, + { + "name": "data", + "assets": [ + { + "name": "test.json", + "srcs": "json/test.json" + } + ] + } + ] +} diff --git a/packages/assets/test/assets/json/asset-manifest.json b/packages/assets/test/assets/json/asset-manifest.json new file mode 100644 index 0000000000..b149b8417a --- /dev/null +++ b/packages/assets/test/assets/json/asset-manifest.json @@ -0,0 +1,47 @@ +{ + "bundles": [ + { + "name": "default", + "assets": [ + { + "name": "bunny", + "srcs": [ + { + "resolution": 1, + "format": "png", + "src": "textures/bunny.png" + } + ] + }, + { + "name": "spritesheet", + "srcs": [ + { + "resolution": 1, + "format": "png", + "src": "spritesheet/spritesheet.json" + } + ] + }, + { + "name": "profile-abel", + "srcs": [ + "textures/profile-abel@0.5x.jpg", + "textures/profile-abel@2x.webp", + "textures/profile-abel@0.5x.jpg", + "textures/profile-abel@2x.webp" + ] + } + ] + }, + { + "name": "data", + "assets": [ + { + "name": "test.json", + "srcs": "json/test.json" + } + ] + } + ] +} diff --git a/packages/assets/test/assets/json/test.json b/packages/assets/test/assets/json/test.json new file mode 100644 index 0000000000..e0ebf283f1 --- /dev/null +++ b/packages/assets/test/assets/json/test.json @@ -0,0 +1,4 @@ +{ + "testNumber": 23, + "testString": "Test String 23" +} diff --git a/packages/assets/test/assets/spritesheet/multi-pack-0.json b/packages/assets/test/assets/spritesheet/multi-pack-0.json new file mode 100644 index 0000000000..e05acdb9da --- /dev/null +++ b/packages/assets/test/assets/spritesheet/multi-pack-0.json @@ -0,0 +1,48 @@ +{"frames": { + +"star1.png": +{ + "frame": {"x":0,"y":0,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} +}, +"star2.png": +{ + "frame": {"x":64,"y":0,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} +}, +"star3.png": +{ + "frame": {"x":128,"y":0,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} +}, +"star4.png": +{ + "frame": {"x":192,"y":0,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} +}}, +"animations": { + "star": ["star1.png","star2.png","star3.png","star4.png"] +}, +"meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "1.0", + "image": "multi-pack-0.png", + "format": "RGBA8888", + "size": {"w":256,"h":64}, + "scale": "1", + "related_multi_packs": [ "multi-pack-1.json" ], + "smartupdate": "$TexturePacker:SmartUpdate:5f1e3e3af16b7f3e5f4097ccfd45634f:8acde9d234ecca966a410602c71bffad:e9ee6f100f514069f43ab3a680b02726$" +} +} diff --git a/packages/assets/test/assets/spritesheet/multi-pack-0.png b/packages/assets/test/assets/spritesheet/multi-pack-0.png new file mode 100644 index 0000000000..52c1171d01 Binary files /dev/null and b/packages/assets/test/assets/spritesheet/multi-pack-0.png differ diff --git a/packages/assets/test/assets/spritesheet/multi-pack-1.json b/packages/assets/test/assets/spritesheet/multi-pack-1.json new file mode 100644 index 0000000000..ff4c21ff7b --- /dev/null +++ b/packages/assets/test/assets/spritesheet/multi-pack-1.json @@ -0,0 +1,23 @@ +{"frames": { + +"goldmine_10_5.png": +{ + "frame": {"x":0,"y":0,"w":190,"h":229}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":190,"h":229}, + "sourceSize": {"w":190,"h":229} +}}, +"animations": { +}, +"meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "1.0", + "image": "multi-pack-1.png", + "format": "RGBA8888", + "size": {"w":190,"h":229}, + "scale": "1", + "related_multi_packs": [ "multi-pack-0.json" ], + "smartupdate": "$TexturePacker:SmartUpdate:5f1e3e3af16b7f3e5f4097ccfd45634f:8acde9d234ecca966a410602c71bffad:e9ee6f100f514069f43ab3a680b02726$" +} +} diff --git a/packages/assets/test/assets/spritesheet/multi-pack-1.png b/packages/assets/test/assets/spritesheet/multi-pack-1.png new file mode 100644 index 0000000000..639b33f660 Binary files /dev/null and b/packages/assets/test/assets/spritesheet/multi-pack-1.png differ diff --git a/packages/assets/test/assets/spritesheet/spritesheet.json b/packages/assets/test/assets/spritesheet/spritesheet.json new file mode 100644 index 0000000000..2b1d2609fd --- /dev/null +++ b/packages/assets/test/assets/spritesheet/spritesheet.json @@ -0,0 +1,55 @@ +{ + "frames": { + "pic-sensei.jpg": { + "frame": { + "x": 1, + "y": 1, + "w": 125, + "h": 125 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 125, + "h": 125 + }, + "sourceSize": { + "w": 125, + "h": 125 + } + }, + "bunny.png": { + "frame": { + "x": 127, + "y": 1, + "w": 7, + "h": 10 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 7, + "h": 10 + }, + "sourceSize": { + "w": 7, + "h": 10 + } + } + }, + "meta": { + "app": "http://github.com/odrick/free-tex-packer-core", + "version": "0.3.4", + "image": "spritesheet.png", + "format": "RGBA8888", + "size": { + "w": 134, + "h": 126 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/packages/assets/test/assets/spritesheet/spritesheet.png b/packages/assets/test/assets/spritesheet/spritesheet.png new file mode 100644 index 0000000000..16a05ed0ae Binary files /dev/null and b/packages/assets/test/assets/spritesheet/spritesheet.png differ diff --git a/packages/assets/test/assets/textures/bunny-2.png b/packages/assets/test/assets/textures/bunny-2.png new file mode 100755 index 0000000000..79c3167508 Binary files /dev/null and b/packages/assets/test/assets/textures/bunny-2.png differ diff --git a/packages/assets/test/assets/textures/bunny.png b/packages/assets/test/assets/textures/bunny.png new file mode 100755 index 0000000000..79c3167508 Binary files /dev/null and b/packages/assets/test/assets/textures/bunny.png differ diff --git a/packages/assets/test/assets/textures/bunny.webp b/packages/assets/test/assets/textures/bunny.webp new file mode 100644 index 0000000000..61898161a4 Binary files /dev/null and b/packages/assets/test/assets/textures/bunny.webp differ diff --git a/packages/assets/test/assets/textures/profile-abel@0.5x.jpg b/packages/assets/test/assets/textures/profile-abel@0.5x.jpg new file mode 100644 index 0000000000..4fa857d9b0 Binary files /dev/null and b/packages/assets/test/assets/textures/profile-abel@0.5x.jpg differ diff --git a/packages/assets/test/assets/textures/profile-abel@0.5x.webp b/packages/assets/test/assets/textures/profile-abel@0.5x.webp new file mode 100644 index 0000000000..a9c370a3d2 Binary files /dev/null and b/packages/assets/test/assets/textures/profile-abel@0.5x.webp differ diff --git a/packages/assets/test/assets/textures/profile-abel@2x.jpg b/packages/assets/test/assets/textures/profile-abel@2x.jpg new file mode 100644 index 0000000000..4624b746ac Binary files /dev/null and b/packages/assets/test/assets/textures/profile-abel@2x.jpg differ diff --git a/packages/assets/test/assets/textures/profile-abel@2x.webp b/packages/assets/test/assets/textures/profile-abel@2x.webp new file mode 100644 index 0000000000..2d97896076 Binary files /dev/null and b/packages/assets/test/assets/textures/profile-abel@2x.webp differ diff --git a/packages/assets/test/cache.tests.ts b/packages/assets/test/cache.tests.ts new file mode 100644 index 0000000000..7061dd0009 --- /dev/null +++ b/packages/assets/test/cache.tests.ts @@ -0,0 +1,93 @@ +import type { CacheParser } from '@pixi/assets'; +import { Cache } from '@pixi/assets'; + +const testParser = { + test: (asset: string) => typeof asset === 'string', + getCacheableAssets: (keys: string[], asset: string) => + { + const out: Record = {}; + + keys.forEach((key) => + { + out[key] = `${asset}-${key}`; + }); + + return out; + } +}as CacheParser; + +describe('Cache', () => +{ + beforeEach(() => + { + Cache.reset(); + Cache.removeParser(testParser); + }); + + it('should add and remove a plugin', () => + { + Cache.addParser(testParser); + + expect(Cache.parsers).toHaveLength(3); + + Cache.removeParser(testParser); + + expect(Cache.parsers).toHaveLength(2); + }); + + it('should process a custom parsers correctly', () => + { + Cache.addParser(testParser); + + Cache.set('test', 'hello'); + + const out = Cache.get('test'); + + expect(out).toBe('hello-test'); + }); + + it('should process multiple keys with a custom parser correctly', () => + { + Cache.addParser(testParser); + + Cache.set(['test', 'chicken'], 'hello'); + + const out = Cache.get('test'); + + expect(out).toBe('hello-test'); + + const chicken = Cache.get('chicken'); + + expect(chicken).toBe('hello-chicken'); + }); + + it('should remove keys with a custom parsers correctly', () => + { + Cache.addParser(testParser); + + Cache.set('test', 'hello'); + + Cache.remove('test'); + + const out = Cache.get('test'); + + expect(out).toBe(undefined); + }); + + it('should remove multiple keys with a custom parser correctly', () => + { + Cache.addParser(testParser); + + Cache.set(['test', 'chicken'], 'hello'); + + Cache.remove('test'); + + const out = Cache.get('test'); + + expect(out).toBe(undefined); + + const chicken = Cache.get('chicken'); + + expect(chicken).toBe(undefined); + }); +}); diff --git a/packages/assets/test/loader-compressed.tests.ts b/packages/assets/test/loader-compressed.tests.ts new file mode 100644 index 0000000000..8f8e993aa2 --- /dev/null +++ b/packages/assets/test/loader-compressed.tests.ts @@ -0,0 +1,54 @@ +import type { Texture } from '@pixi/core'; +import { Loader } from '../src/loader/Loader'; +import { loadDDS, loadKTX } from '@pixi/assets'; + +describe('Compressed Loader', () => +{ + it('should load a ktx image', async () => + { + const loader = new Loader(); + + loader.addParser(loadKTX); + + // eslint-disable-next-line max-len + const texture: Texture = await loader.load(`https://pixijs.io/compressed-textures-example/images/PixiJS-Logo_PNG_BC3_KTX.KTX`); + + expect(texture.baseTexture.valid).toBe(true); + expect(texture.width).toBe(898); + expect(texture.height).toBe(227); + }); + + it('should load a a DDS image', async () => + { + const loader = new Loader(); + + loader.addParser(loadDDS); + + // eslint-disable-next-line max-len + const texture: Texture = await loader.load(`https://pixijs.io/compressed-textures-example/images/airplane-boeing_JPG_BC3_1.DDS`); + + expect(texture.baseTexture.valid).toBe(true); + expect(texture.width).toBe(1000); + expect(texture.height).toBe(664); + }); + + // CRASHES JEST - ELECTRON + // it.only('should load a a Basis image', async () => + // { + // await BasisLoader.loadTranscoder( + // 'https://cdn.jsdelivr.net/npm/@pixi/basis@6.3.2/assets/basis_transcoder.js', + // 'https://cdn.jsdelivr.net/npm/@pixi/basis@6.3.2/assets/basis_transcoder.wasm' + // ); + + // // console.log('DOING!!'); + // const loader = new Loader(); + + // loader.addParser(loadBasis); + + // const texture: Texture = await loader.load(`https://pixijs.io/compressed-textures-example/images/kodim20.basis`); + + // // expect(texture.baseTexture.valid).toBe(true); + // // expect(texture.width).toBe(1000); + // // expect(texture.height).toBe(664); + // }); +}); diff --git a/packages/assets/test/loader.tests.ts b/packages/assets/test/loader.tests.ts new file mode 100644 index 0000000000..d7b3ad7cf7 --- /dev/null +++ b/packages/assets/test/loader.tests.ts @@ -0,0 +1,356 @@ +import { Texture } from '@pixi/core'; +import type { Spritesheet } from '@pixi/spritesheet'; +import { BitmapFont } from '@pixi/text-bitmap'; + +import type { + LoaderParser } from '@pixi/assets'; +import { + Cache, + cacheSpritesheet, + loadBitmapFont, + loadJson, + loadSpritesheet, + loadTextures, + loadTxt, + loadWebFont +} from '@pixi/assets'; +import { Loader } from '../src/loader/Loader'; + +const dummyPlugin: LoaderParser = { + async load(url: string): Promise + { + return url; + }, +} as LoaderParser; + +describe('Loader', () => +{ + const serverPath = process.env.GITHUB_ACTIONS + ? `https://raw.githubusercontent.com/pixijs/pixijs/${process.env.GITHUB_SHA}/packages/assets/test/assets/` + : 'http://localhost:8080/assets/test/assets/'; + + beforeEach(() => + { + Cache.reset(); + }); + + it('should add and remove a plugin', () => + { + const loader = new Loader(); + + loader.addParser(dummyPlugin); + + expect(loader.parsers).toHaveLength(1); + + loader.removeParser(dummyPlugin); + + expect(loader.parsers).toHaveLength(0); + }); + + it('should load a single image', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures); + + const texture: Texture = await loader.load(`${serverPath}textures/bunny.png`); + + expect(texture.baseTexture.valid).toBe(true); + expect(texture.width).toBe(26); + expect(texture.height).toBe(37); + }); + + it('should load a single image one after multiple loads', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures); + + const texturesPromises = []; + + for (let i = 0; i < 10; i++) + { + texturesPromises.push(loader.load(`${serverPath}textures/bunny.png`)); + } + + const textures = await Promise.all(texturesPromises); + + const ogTexture = textures[0]; + + textures.forEach((texture) => + { + expect(texture).toBe(ogTexture); + }); + }); + + it('should load multiple images', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures); + + const assetsUrls = [`${serverPath}textures/bunny.png`, `${serverPath}textures/bunny-2.png`]; + + const textures = await loader.load(assetsUrls); + + expect(textures[`${serverPath}textures/bunny.png`]).toBeInstanceOf(Texture); + expect(textures[`${serverPath}textures/bunny-2.png`]).toBeInstanceOf(Texture); + }); + + it('should load json file', async () => + { + const loader = new Loader(); + + loader.addParser(loadJson); + + const json = await loader.load(`${serverPath}json/test.json`); + + expect(json).toEqual({ + testNumber: 23, + testString: 'Test String 23', + }); + }); + + it('should load a spritesheet', async () => + { + const loader = new Loader(); + + loader.addParser(loadJson, loadTextures, loadSpritesheet); + + const spriteSheet: Spritesheet = await loader.load(`${serverPath}spritesheet/spritesheet.json`); + + const bunnyTexture = spriteSheet.textures['bunny.png']; + const senseiTexture = spriteSheet.textures['pic-sensei.jpg']; + + expect(bunnyTexture).toBeInstanceOf(Texture); + expect(senseiTexture).toBeInstanceOf(Texture); + + expect(bunnyTexture.baseTexture).toBe(senseiTexture.baseTexture); + + expect(bunnyTexture.baseTexture.valid).toBe(true); + expect(bunnyTexture.width).toBe(7); + expect(bunnyTexture.height).toBe(10); + + expect(senseiTexture.baseTexture.valid).toBe(true); + expect(senseiTexture.width).toBe(125); + expect(senseiTexture.height).toBe(125); + }); + + it('should load a multi packed spritesheet', async () => + { + const loader = new Loader(); + + Cache.addParser(cacheSpritesheet); + + loader.addParser(loadJson, loadTextures, loadSpritesheet); + + const spritesheet = await loader.load(`${serverPath}spritesheet/multi-pack-0.json`) as Spritesheet; + + Cache.set('spritesheet/multi-pack-0.json', spritesheet); + + const pack0 = Cache.get('star1.png'); + const pack1 = Cache.get('goldmine_10_5.png'); + + expect(pack0).toBeInstanceOf(Texture); + expect(pack1).toBeInstanceOf(Texture); + + expect(pack0.baseTexture.valid).toBe(true); + expect(pack0.width).toBe(64); + expect(pack0.height).toBe(64); + + expect(pack1.baseTexture.valid).toBe(true); + expect(pack1.width).toBe(190); + expect(pack1.height).toBe(229); + }); + + it('should load a bitmap font', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures, loadBitmapFont); + + const bitmapFont: BitmapFont = await loader.load(`${serverPath}bitmap-font/desyrel.xml`); + const bitmapFont2: BitmapFont = await loader.load(`${serverPath}bitmap-font/font.fnt`); + + expect(bitmapFont).toBeInstanceOf(BitmapFont); + expect(bitmapFont2).toBeInstanceOf(BitmapFont); + }); + + it('should load a bitmap font text file', async () => + { + const loader = new Loader(); + + loader.addParser(loadTxt, loadTextures, loadBitmapFont); + + const bitmapFont: BitmapFont = await loader.load(`${serverPath}bitmap-font/bmtxt-test.txt`); + + expect(bitmapFont).toBeInstanceOf(BitmapFont); + }); + + it('should load a bitmap font sdf / msdf', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures, loadBitmapFont); + + const bitmapFont: BitmapFont = await loader.load(`${serverPath}bitmap-font/msdf.fnt`); + const bitmapFont2: BitmapFont = await loader.load(`${serverPath}bitmap-font/sdf.fnt`); + + expect(bitmapFont).toBeInstanceOf(BitmapFont); + expect(bitmapFont2).toBeInstanceOf(BitmapFont); + expect(bitmapFont.distanceFieldType).toEqual('msdf'); + expect(bitmapFont2.distanceFieldType).toEqual('sdf'); + }); + + it('should load a split bitmap font', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures, loadBitmapFont); + + const bitmapFont: BitmapFont = await loader.load(`${serverPath}bitmap-font/split_font.fnt`); + + expect(bitmapFont).toBeInstanceOf(BitmapFont); + }); + + it('should load a web font', async () => + { + const loader = new Loader(); + + loader.addParser(loadWebFont); + + const font = await loader.load(`${serverPath}fonts/outfit.woff2`); + + let foundFont = false; + + document.fonts.forEach((f: FontFace) => + { + if (f.family === 'Outfit.woff2') + { + foundFont = true; + } + }); + + document.fonts.delete(font); + + expect(foundFont).toBe(true); + }); + + it('should load a web font with custom attributes', async () => + { + const loader = new Loader(); + + document.fonts.clear(); + loader.addParser(loadWebFont); + + const font = await loader.load({ + data: { + family: 'Overridden', + style: 'italic', + weights: ['normal'], + }, + src: `${serverPath}fonts/outfit.woff2`, + }); + + let count = 0; + + document.fonts.forEach((f: FontFace) => + { + count++; + expect(f.family).toBe('Overridden'); + expect(f.weight).toBe('normal'); + expect(f.style).toBe('italic'); + }); + + document.fonts.delete(font); + + expect(count).toBe(1); + }); + + it('should load with metaData', async () => + { + const loader = new Loader(); + + loader.addParser({ + test: () => true, + load: async (url, options) => + url + options.data.whatever, + } as LoaderParser); + + const sillyID: string = await loader.load({ + src: `${serverPath}textures/bunny.png`, + data: { whatever: 23 }, + }); + + expect(sillyID).toBe(`${serverPath}textures/bunny.png23`); + }); + + it('should unload a texture', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures); + + const texture: Texture = await loader.load(`${serverPath}textures/bunny.png`); + + expect(texture.baseTexture.destroyed).toBe(false); + + const baseTexture = texture.baseTexture; + + await loader.unload(`${serverPath}textures/bunny.png`); + + expect(texture.baseTexture).toBe(null); + expect(baseTexture.destroyed).toBe(true); + }); + + it('should unload a spritesheet', async () => + { + const loader = new Loader(); + + loader.addParser(loadJson, loadTextures, loadSpritesheet); + + const spriteSheet: Spritesheet = await loader.load(`${serverPath}spritesheet/spritesheet.json`); + + await loader.unload(`${serverPath}spritesheet/spritesheet.json`); + + expect(spriteSheet.baseTexture).toBe(null); + }); + + it('should unload a bitmap font', async () => + { + const loader = new Loader(); + + loader.addParser(loadTextures, loadBitmapFont); + + const bitmapFont: BitmapFont = await loader.load(`${serverPath}bitmap-font/desyrel.xml`); + + expect(bitmapFont).toBeInstanceOf(BitmapFont); + + await loader.unload(`${serverPath}bitmap-font/desyrel.xml`); + + expect(bitmapFont.pageTextures).toBe(null); + }); + + it('should unload a web font', async () => + { + const loader = new Loader(); + + loader.addParser(loadWebFont); + + await loader.load(`${serverPath}fonts/outfit.woff2`); + + await loader.unload(`${serverPath}fonts/outfit.woff2`); + + let foundFont = false; + + document.fonts.forEach((f: FontFace) => + { + if (f.family === 'Outfit.woff2') + { + foundFont = true; + } + }); + + expect(foundFont).toBe(false); + }); +}); diff --git a/packages/assets/test/resolver.tests.ts b/packages/assets/test/resolver.tests.ts new file mode 100644 index 0000000000..464aca4ac7 --- /dev/null +++ b/packages/assets/test/resolver.tests.ts @@ -0,0 +1,486 @@ +import { spriteSheetUrlParser, textureUrlParser } from '@pixi/assets'; +import { Resolver } from '../src/resolver/Resolver'; +import { manifest } from './sampleManifest'; + +describe('Resolver', () => +{ + it('should resolve asset', () => + { + const resolver = new Resolver(); + + [ + { + preferOrder: { + priority: ['resolution', 'format'], + params: { + format: ['png', 'webp'], + resolution: [2, 1], + }, + }, + expected: 'my-image@2x.png', + }, + { + preferOrder: { + params: { + resolution: [1], + format: ['png', 'webp'], + }, + priority: ['resolution', 'format'], + }, + expected: 'my-image.png', + }, + { + preferOrder: { + params: { + resolution: [1], + format: ['webp', 'png'], + }, + priority: ['resolution', 'format'], + }, + expected: 'my-image.webp', + }, + { + preferOrder: { + params: { + resolution: [2, 1], + format: ['webp', 'png'], + }, + priority: ['format', 'resolution'], + }, + expected: 'my-image.webp', + }, + { + preferOrder: { + params: { + resolution: [2, 1], + format: ['webp', 'png'], + }, + priority: ['resolution', 'format'], + }, + expected: 'my-image@2x.png', + }, + + ].forEach((testData, i) => + { + if (i !== 0) return; + + resolver.reset(); + + resolver.prefer(testData.preferOrder); + + resolver.add('test', [ + { + resolution: 1, + format: 'png', + src: 'my-image.png', + }, + { + resolution: 2, + format: 'png', + src: 'my-image@2x.png', + }, + { + resolution: 1, + format: 'webp', + src: 'my-image.webp', + }, + ]); + + const asset = resolver.resolveUrl('test'); + + expect(asset).toBe(testData.expected); + }); + }); + + it('should resolve the correct texture', () => + { + const resolver = new Resolver(); + + resolver.prefer({ + priority: ['resolution', 'format'], + params: { + format: ['jpg'], + resolution: [2], + }, + }); + + resolver.addUrlParser(textureUrlParser); + + resolver.add('test', [ + 'profile-abel@0.5x.jpg', + 'profile-abel@2x.jpg', + 'profile-abel@0.5x.webp', + 'profile-abel@2x.webp', + ]); + + expect(resolver.resolveUrl('test')).toBe('profile-abel@2x.jpg'); + }); + + it('should resolve asset without priority', () => + { + const resolver = new Resolver(); + + [ + { + preferOrder: { + params: { + resolution: [2], + format: ['webp', 'png'], + }, + }, + expected: 'my-image@2x.png', + }, + { + preferOrder: { + params: { + format: ['webp', 'png'], + resolution: [2], + }, + }, + expected: 'my-image.webp', + }, + ].forEach((testData) => + { + resolver.reset(); + + resolver.add('test', [ + { + resolution: 1, + format: 'png', + src: 'my-image.png', + }, + { + resolution: 2, + format: 'png', + src: 'my-image@2x.png', + }, + { + resolution: 1, + format: 'webp', + src: 'my-image.webp', + }, + ]); + + resolver.prefer(testData.preferOrder); + + const asset = resolver.resolveUrl('test'); + + expect(asset).toBe(testData.expected); + }); + }); + + it('should be able to have resolve to a different string', () => + { + const resolver = new Resolver(); + + resolver.add('test', 'hello'); + + expect(resolver.resolveUrl('test')).toBe('hello'); + }); + + it('should be able to have multiple aliases', () => + { + const resolver = new Resolver(); + + resolver.prefer({ + params: { + resolution: [2, 1], + format: ['webp', 'png'], + }, + priority: ['resolution', 'format'], + }); + + resolver.add(['test', 'test-2', 'test-3'], [{ + resolution: 2, + format: 'png', + src: 'my-image.png', + }]); + + expect(resolver.resolveUrl('test')).toBe('my-image.png'); + expect(resolver.resolveUrl('test-2')).toBe('my-image.png'); + expect(resolver.resolveUrl('test-3')).toBe('my-image.png'); + }); + + it('should set base path correctly on urls', () => + { + const resolver = new Resolver(); + + resolver.basePath = 'http://localhost:8080/'; + + resolver.add('test', 'bunny.png'); + + expect(resolver.resolveUrl('test')).toBe('http://localhost:8080/bunny.png'); + }); + + it('should be able to have multiple preferences', () => + { + const resolver = new Resolver(); + + // check the params when adding! + resolver.prefer({ + params: { + format: ['ogg', 'mp3'], + }, + }); + + resolver.prefer({ + params: { + format: ['webp', 'png'], + resolution: [2, 1], + }, + }); + + resolver.add('test', [{ + resolution: 2, + format: 'png', + src: 'my-image.png', + }]); + + // TODO add default parser that just extracts file format + resolver.add('explode-sound', [ + `explode.mp3`, + `explode.ogg`, + ]); + + expect(resolver.resolveUrl('test')).toBe('my-image.png'); + expect(resolver.resolveUrl('explode-sound')).toBe('explode.ogg'); + }); + + it('should be able to parse strings', () => + { + const resolver = new Resolver(); + + resolver.prefer({ + priority: ['format'], + params: { + format: ['png', 'webp'], + }, + }); + + resolver.add('test', [ + 'my-image.webp', + 'my-image.png', + ]); + + expect(resolver.resolveUrl('test')).toBe('my-image.png'); + }); + + it('should be able to parse strings in a custom way', () => + { + const resolver = new Resolver(); + + resolver.prefer({ + priority: ['resolution', 'format'], + params: { + resolution: [2, 1], + format: ['webp', 'png'], + }, + }); + + resolver.addUrlParser(textureUrlParser); + + resolver.add('test', [ + 'my-image@4x.webp', + 'my-image@2x.webp', + 'my-image@2x.png', + 'my-image.png', + ]); + + expect(resolver.resolveUrl('test')).toBe('my-image@2x.webp'); + }); + + it('should resolve multiple assets', () => + { + const resolver = new Resolver(); + + resolver.add('test', 'out1.png'); + resolver.add('test2', 'out2.png'); + resolver.add('test3', 'out4.png'); + + expect(resolver.resolve(['test', 'test2', 'test3'])).toEqual({ + test: { + alias: ['test'], + format: 'png', + src: 'out1.png', + }, + test2: { + alias: ['test2'], + format: 'png', + src: 'out2.png', + }, + test3: { + alias: ['test3'], + format: 'png', + src: 'out4.png', + }, + }); + + expect(resolver.resolveUrl(['test', 'test2', 'test3'])).toEqual({ + test: 'out1.png', + test2: 'out2.png', + test3: 'out4.png', + }); + }); + + it('should resolve a bundle id correctly', () => + { + const resolver = new Resolver(); + + resolver.prefer({ + params: { + resolution: [2, 1], + format: ['webp', 'png'], + }, + }); + + resolver.add('test', [ + { + resolution: 2, + format: 'webp', + src: 'my-spritesheet@2x.webp.json', + }, + { + resolution: 0.5, + format: 'webp', + src: 'my-spritesheet@0.5x.webp.json', + }, + { + resolution: 2, + format: 'png', + src: 'my-spritesheet@2x.png.json', + }, + { + resolution: 0.5, + format: 'png', + src: 'my-spritesheet@0.5x.png.json', + }, + ]); + + expect(resolver.resolveUrl('test')).toBe('my-spritesheet@2x.webp.json'); + }); + + it('should get multiple bundle ids correctly', () => + { + const resolver = new Resolver(); + + resolver.addManifest(manifest); + + const assets = resolver.resolveBundle(['default', 'level']); + + expect(assets.level).toEqual({ + image3: { + alias: ['image3'], + format: 'png', + resolution: 1, + src: 'chicken.png', + }, + }); + + expect(assets.default).toEqual({ + image1: { + alias: ['image1'], + format: 'png', + resolution: 1, + src: 'my-sprite@2x.png', + }, + levelData: { + alias: [ + 'levelData', + ], + format: 'json', + src: 'levelData.json', + }, + spriteSheet1: { + alias: ['spriteSheet1'], + format: 'png', + resolution: 1, + src: 'my-sprite-sheet.json', + }, + spriteSheet2: { + alias: ['spriteSheet2'], + format: 'png', + resolution: 1, + src: 'my-sprite-sheet-2.json', + }, + }); + }); + + it('should resolve a bundle correctly', () => + { + const resolver = new Resolver(); + + resolver.addManifest(manifest); + + const assets = resolver.resolveUrl(['image1', 'levelData', 'spriteSheet1', 'spriteSheet2']); + + expect(assets).toEqual({ + image1: 'my-sprite@2x.png', + levelData: 'levelData.json', + spriteSheet1: 'my-sprite-sheet.json', + spriteSheet2: 'my-sprite-sheet-2.json', + }); + }); + + it('should parse a string sprite sheet correctly', () => + { + [ + { + url: 'my-sprite-sheet.json', + pass: false, + }, + { + url: 'my-sprite-sheet@0.5x.webp.json', + pass: true, + result: { + format: 'webp', + resolution: 0.5, + src: 'my-sprite-sheet@0.5x.webp.json', + }, + }, + { + url: 'my-sprite-sheet@2x.png.json', + pass: true, + result: { + format: 'png', + resolution: 2, + src: 'my-sprite-sheet@2x.png.json', + }, + }, + { + url: 'my-sprite-sheet@2x.json', + pass: false, + }, + ].forEach((toTest) => + { + const pass = spriteSheetUrlParser.test(toTest.url); + + expect(pass).toBe(toTest.pass); + + if (pass) + { + expect(spriteSheetUrlParser.parse(toTest.url)).toEqual(toTest.result); + } + }); + }); + + it('should be able to have resolve with a single string with {} options', () => + { + const resolver = new Resolver(); + + resolver.add('test', 'my-image.{png, webp}'); + + expect(resolver.resolveUrl('test')).toBe('my-image.png'); + + resolver.reset(); + + resolver.add('test', 'my-image.{png,webp}'); + + resolver.prefer({ + params: { + format: ['webp', 'png'], + }, + }); + + expect(resolver.resolveUrl('test')).toBe('my-image.webp'); + }); +}); diff --git a/packages/assets/test/sampleManifest.ts b/packages/assets/test/sampleManifest.ts new file mode 100644 index 0000000000..f041aa1ed3 --- /dev/null +++ b/packages/assets/test/sampleManifest.ts @@ -0,0 +1,75 @@ +import type { ResolverManifest } from '../src/resolver/types'; + +export const manifest: ResolverManifest = { + bundles: [ + { + name: 'default', + assets: [ + { + name: 'image1', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite@2x.png', + }, + { + resolution: 2, + format: 'png', + src: 'my-image@2x.png', + }, + ], + }, + { + name: 'spriteSheet1', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite-sheet.json', + }, + { + resolution: 2, + format: 'png', + src: 'my-sprite-sheet@2x.json', + }, + ], + }, + { + name: `spriteSheet2`, + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite-sheet-2.json', + }, + ], + }, + { + name: 'levelData', + srcs: 'levelData.json', + }, + ], + }, + { + name: 'level', + assets: [ + { + name: 'image3', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'chicken.png', + }, + { + resolution: 2, + format: 'png', + src: 'chicken@2x.png', + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/assets/test/sampleManifests.ts b/packages/assets/test/sampleManifests.ts new file mode 100644 index 0000000000..f041aa1ed3 --- /dev/null +++ b/packages/assets/test/sampleManifests.ts @@ -0,0 +1,75 @@ +import type { ResolverManifest } from '../src/resolver/types'; + +export const manifest: ResolverManifest = { + bundles: [ + { + name: 'default', + assets: [ + { + name: 'image1', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite@2x.png', + }, + { + resolution: 2, + format: 'png', + src: 'my-image@2x.png', + }, + ], + }, + { + name: 'spriteSheet1', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite-sheet.json', + }, + { + resolution: 2, + format: 'png', + src: 'my-sprite-sheet@2x.json', + }, + ], + }, + { + name: `spriteSheet2`, + srcs: [ + { + resolution: 1, + format: 'png', + src: 'my-sprite-sheet-2.json', + }, + ], + }, + { + name: 'levelData', + srcs: 'levelData.json', + }, + ], + }, + { + name: 'level', + assets: [ + { + name: 'image3', + srcs: [ + { + resolution: 1, + format: 'png', + src: 'chicken.png', + }, + { + resolution: 2, + format: 'png', + src: 'chicken@2x.png', + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/assets/test/utils.tests.ts b/packages/assets/test/utils.tests.ts new file mode 100644 index 0000000000..976d948374 --- /dev/null +++ b/packages/assets/test/utils.tests.ts @@ -0,0 +1,35 @@ +import { createStringVariations } from '@pixi/assets'; + +describe('Utils', () => +{ + it('createStringVariations should parse correctly', () => + { + const out = createStringVariations('hell@{2,1,0.5}x.{png,webp,avif}'); + + expect(out).toEqual([ + 'hell@2x.png', + 'hell@2x.webp', + 'hell@2x.avif', + 'hell@1x.png', + 'hell@1x.webp', + 'hell@1x.avif', + 'hell@0.5x.png', + 'hell@0.5x.webp', + 'hell@0.5x.avif', + ]); + + const out2 = createStringVariations('name is {chicken,wolf,sheep}'); + + expect(out2).toEqual([ + 'name is chicken', + 'name is wolf', + 'name is sheep', + ]); + + const out3 = createStringVariations('hell@2x.png'); + + expect(out3).toEqual([ + 'hell@2x.png', + ]); + }); +}); diff --git a/packages/basis/src/BasisLoader.ts b/packages/basis/src/BasisLoader.ts index 4789e20f62..1995f01eaa 100644 --- a/packages/basis/src/BasisLoader.ts +++ b/packages/basis/src/BasisLoader.ts @@ -1,26 +1,20 @@ -import { TYPES, MIPMAP_MODES, ALPHA_MODES, FORMATS } from '@pixi/constants'; -import type { ExtensionMetadata } from '@pixi/core'; -import { BaseTexture, BufferResource, ExtensionType, Texture } from '@pixi/core'; +import type { TYPES } from '@pixi/constants'; +import { MIPMAP_MODES, ALPHA_MODES, FORMATS } from '@pixi/constants'; +import type { BufferResource, ExtensionMetadata } from '@pixi/core'; +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; import { CompressedTextureResource } from '@pixi/compressed-textures'; import type { - BasisTextureExtensions, BasisBinding } from './Basis'; import { BASIS_FORMATS, - BASIS_FORMAT_TO_INTERNAL_FORMAT, - INTERNAL_FORMAT_TO_BASIS_FORMAT, - BASIS_FORMATS_ALPHA, BASIS_FORMAT_TO_TYPE, } from './Basis'; import { TranscoderWorker } from './TranscoderWorker'; import { LoaderResource } from '@pixi/loaders'; import type { IResourceMetadata } from '@pixi/loaders'; -import type { CompressedLevelBuffer, INTERNAL_FORMATS } from '@pixi/compressed-textures'; - -type TranscodedResourcesArray = (Array | Array) & { - basisFormat: BASIS_FORMATS -}; +import type { TranscodedResourcesArray } from './BasisParser'; +import { BasisParser } from './BasisParser'; /** * Result when calling registerCompressedTextures. @@ -69,12 +63,6 @@ export class BasisLoader /** @ignore */ static extension: ExtensionMetadata = ExtensionType.Loader; - private static basisBinding: BasisBinding; - private static defaultRGBFormat: { basisFormat: BASIS_FORMATS, textureFormat: INTERNAL_FORMATS | TYPES }; - private static defaultRGBAFormat: { basisFormat: BASIS_FORMATS, textureFormat: INTERNAL_FORMATS | TYPES }; - private static fallbackMode = false; - private static workerPool: TranscoderWorker[] = []; - /** * Transcodes the *.basis data when the data is loaded. If the transcoder is not bound yet, it * will hook transcoding to {@link BasisResource#onTranscoderInitialized}. @@ -86,59 +74,28 @@ export class BasisLoader { if (resource.extension === 'basis' && resource.data) { - if (!!BasisLoader.basisBinding || (!!BasisLoader.TranscoderWorker.wasmSource)) + (async () => { - BasisLoader.transcode(resource, next); - } - else - { - TranscoderWorker.onTranscoderInitialized.add(() => + if (!BasisParser.basisBinding && (!BasisParser.TranscoderWorker.wasmSource)) { - BasisLoader.transcode(resource, next); - }); - } - } - else - { - next(); - } - } + await TranscoderWorker.onTranscoderInitialized; + } - /** - * Runs transcoding and populates {@link imageArray}. It will run the transcoding in a web worker - * if they are available. - * @private - */ - private static async transcode(resource: LoaderResource, next: (...args: any[]) => void): Promise - { - try - { - const data: ArrayBuffer = resource.data; - let resources: TranscodedResourcesArray; + const resources = await BasisParser.transcode(resource.data); - if (typeof Worker !== 'undefined' && BasisLoader.TranscoderWorker.wasmSource) - { - resources = await BasisLoader.transcodeAsync(data); - } - else - { - resources = BasisLoader.transcodeSync(data); - } + Object.assign(resource, BasisLoader.registerTextures( + resource.url, + resources, + resource.metadata, + )); - Object.assign(resource, BasisLoader.registerTextures( - resource.url, - resources, - resource.metadata, - )); + next(); + })(); } - catch (err) + else { - next(err); - - return; + next(); } - - next(); } /** @@ -202,290 +159,6 @@ export class BasisLoader return result; } - /** - * Finds a suitable worker for transcoding and sends a transcoding request - * @private - * @async - */ - private static async transcodeAsync(arrayBuffer: ArrayBuffer): Promise - { - const workerPool = BasisLoader.workerPool; - - let leastLoad = 0x10000000; - let worker = null; - - for (let i = 0, j = workerPool.length; i < j; i++) - { - if (workerPool[i].load < leastLoad) - { - worker = workerPool[i]; - leastLoad = worker.load; - } - } - - if (!worker) - { - /* eslint-disable-next-line no-use-before-define */ - worker = new TranscoderWorker(); - - workerPool.push(worker); - } - - // Wait until worker is ready - await worker.initAsync(); - - const response = await worker.transcodeAsync( - new Uint8Array(arrayBuffer), - BasisLoader.defaultRGBAFormat.basisFormat, - BasisLoader.defaultRGBFormat.basisFormat, - ); - - const basisFormat = response.basisFormat; - const imageArray = response.imageArray; - - // whether it is an uncompressed format - const fallbackMode = basisFormat > 12; - let imageResources: TranscodedResourcesArray; - - if (!fallbackMode) - { - const format = BASIS_FORMAT_TO_INTERNAL_FORMAT[response.basisFormat]; - - // HINT: this.imageArray is CompressedTextureResource[] - imageResources = new Array(imageArray.length) as TranscodedResourcesArray; - - for (let i = 0, j = imageArray.length; i < j; i++) - { - imageResources[i] = new CompressedTextureResource(null, { - format, - width: imageArray[i].width, - height: imageArray[i].height, - levelBuffers: imageArray[i].levelArray, - levels: imageArray[i].levelArray.length, - }); - } - } - else - { - // TODO: BufferResource does not support manual mipmapping. - imageResources = imageArray.map((image) => new BufferResource( - new Uint16Array(image.levelArray[0].levelBuffer.buffer), { - width: image.width, - height: image.height, - }), - ) as TranscodedResourcesArray; - } - - imageResources.basisFormat = basisFormat; - - return imageResources; - } - - /** - * Runs transcoding on the main thread. - * @private - */ - private static transcodeSync(arrayBuffer: ArrayBuffer): TranscodedResourcesArray - { - const BASIS = BasisLoader.basisBinding; - - const data = new Uint8Array(arrayBuffer); - const basisFile = new BASIS.BasisFile(data); - const imageCount = basisFile.getNumImages(); - const hasAlpha = basisFile.getHasAlpha(); - - const basisFormat = hasAlpha - ? BasisLoader.defaultRGBAFormat.basisFormat - : BasisLoader.defaultRGBFormat.basisFormat; - const basisFallbackFormat = BASIS_FORMATS.cTFRGB565; - const imageResources = new Array(imageCount); - - let fallbackMode = BasisLoader.fallbackMode; - - if (!basisFile.startTranscoding()) - { - // #if _DEBUG - console.error(`Basis failed to start transcoding!`); - // #endif - - basisFile.close(); - basisFile.delete(); - - return null; - } - - for (let i = 0; i < imageCount; i++) - { - // Don't transcode all mipmap levels in fallback mode! - const levels = !fallbackMode ? basisFile.getNumLevels(i) : 1; - const width = basisFile.getImageWidth(i, 0); - const height = basisFile.getImageHeight(i, 0); - const alignedWidth = (width + 3) & ~3; - const alignedHeight = (height + 3) & ~3; - - const imageLevels = new Array(levels); - - // Transcode mipmap levels into "imageLevels" - for (let j = 0; j < levels; j++) - { - const levelWidth = basisFile.getImageWidth(i, j); - const levelHeight = basisFile.getImageHeight(i, j); - const byteSize = basisFile.getImageTranscodedSizeInBytes( - i, 0, !fallbackMode ? basisFormat : basisFallbackFormat); - - imageLevels[j] = { - levelID: j, - levelBuffer: new Uint8Array(byteSize), - levelWidth, - levelHeight, - }; - - if (!basisFile.transcodeImage( - imageLevels[j].levelBuffer, i, 0, !fallbackMode ? basisFormat : basisFallbackFormat, false, false)) - { - if (fallbackMode) - { - // #if _DEBUG - console.error(`Basis failed to transcode image ${i}, level ${0}!`); - // #endif - break; - } - else - { - // Try transcoding to an uncompressed format before giving up! - // NOTE: We must start all over again as all Resources must be in compressed OR uncompressed. - i = -1; - fallbackMode = true; - - // #if _DEBUG - /* eslint-disable-next-line max-len */ - console.warn(`Basis failed to transcode image ${i}, level ${0} to a compressed texture format. Retrying to an uncompressed fallback format!`); - // #endif - continue; - } - } - } - - let imageResource; - - if (!fallbackMode) - { - imageResource = new CompressedTextureResource(null, { - format: BASIS_FORMAT_TO_INTERNAL_FORMAT[basisFormat], - width: alignedWidth, - height: alignedHeight, - levelBuffers: imageLevels, - levels, - }); - } - else - { - // TODO: BufferResource doesn't support manual mipmap levels - imageResource = new BufferResource( - new Uint16Array(imageLevels[0].levelBuffer.buffer), { width, height }); - } - - imageResources[i] = imageResource; - } - - basisFile.close(); - basisFile.delete(); - - const transcodedResources = imageResources as TranscodedResourcesArray; - - transcodedResources.basisFormat = !fallbackMode ? basisFormat : basisFallbackFormat; - - return transcodedResources; - } - - /** - * Detects the available compressed texture formats on the device. - * @param extensions - extensions provided by a WebGL context - * @ignore - */ - static autoDetectFormats(extensions?: Partial): void - { - // Auto-detect WebGL compressed-texture extensions - if (!extensions) - { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl'); - - if (!gl) - { - console.error('WebGL not available for BASIS transcoding. Silently failing.'); - - return; - } - - extensions = { - s3tc: gl.getExtension('WEBGL_compressed_texture_s3tc'), - s3tc_sRGB: gl.getExtension('WEBGL_compressed_texture_s3tc_srgb'), /* eslint-disable-line camelcase */ - etc: gl.getExtension('WEBGL_compressed_texture_etc'), - etc1: gl.getExtension('WEBGL_compressed_texture_etc1'), - pvrtc: gl.getExtension('WEBGL_compressed_texture_pvrtc') - || gl.getExtension('WEBKIT_WEBGL_compressed_texture_pvrtc'), - atc: gl.getExtension('WEBGL_compressed_texture_atc'), - astc: gl.getExtension('WEBGL_compressed_texture_astc'), - }; - } - - // Discover the available texture formats - const supportedFormats: { [id: string]: INTERNAL_FORMATS } = {}; - - for (const key in extensions) - { - const extension = (extensions as any)[key]; - - if (!extension) - { - continue; - } - - Object.assign(supportedFormats, Object.getPrototypeOf(extension)); - } - - // Set the default alpha/non-alpha output formats for basisu transcoding - for (let i = 0; i < 2; i++) - { - const detectWithAlpha = !!i; - let internalFormat: number; - let basisFormat: number; - - for (const id in supportedFormats) - { - internalFormat = supportedFormats[id]; - basisFormat = INTERNAL_FORMAT_TO_BASIS_FORMAT[internalFormat]; - - if (basisFormat !== undefined) - { - if ((detectWithAlpha && BASIS_FORMATS_ALPHA[basisFormat]) - || (!detectWithAlpha && !BASIS_FORMATS_ALPHA[basisFormat])) - { - break; - } - } - } - - if (internalFormat) - { - BasisLoader[detectWithAlpha ? 'defaultRGBAFormat' : 'defaultRGBFormat'] = { - textureFormat: internalFormat, - basisFormat, - }; - } - else - { - BasisLoader[detectWithAlpha ? 'defaultRGBAFormat' : 'defaultRGBFormat'] = { - textureFormat: TYPES.UNSIGNED_SHORT_5_6_5, - basisFormat: BASIS_FORMATS.cTFRGB565, - }; - - BasisLoader.fallbackMode = true; - } - } - } - /** * Binds the basis_universal transcoder to decompress *.basis files. You must initialize the transcoder library yourself. * @@ -508,7 +181,7 @@ export class BasisLoader */ static bindTranscoder(basisLibrary: BasisBinding): void { - BasisLoader.basisBinding = basisLibrary; + BasisParser.basisBinding = basisLibrary; } /** @@ -519,7 +192,7 @@ export class BasisLoader */ static loadTranscoder(jsURL: string, wasmURL: string): Promise<[void, void]> { - return BasisLoader.TranscoderWorker.loadTranscoder(jsURL, wasmURL); + return BasisParser.TranscoderWorker.loadTranscoder(jsURL, wasmURL); } /** @@ -530,26 +203,7 @@ export class BasisLoader */ static setTranscoder(jsSource: string, wasmSource: ArrayBuffer): void { - BasisLoader.TranscoderWorker.setTranscoder(jsSource, wasmSource); - } - - static TranscoderWorker: typeof TranscoderWorker = TranscoderWorker; - - static get TRANSCODER_WORKER_POOL_LIMIT(): number - { - return this.workerPool.length || 1; - } - - static set TRANSCODER_WORKER_POOL_LIMIT(limit: number) - { - // TODO: Destroy workers? - for (let i = this.workerPool.length; i < limit; i++) - { - this.workerPool[i] = new TranscoderWorker(); - this.workerPool[i].initAsync(); - } + BasisParser.TranscoderWorker.setTranscoder(jsSource, wasmSource); } } -// Auto-detect compressed texture formats once @pixi/basis is imported! -BasisLoader.autoDetectFormats(); diff --git a/packages/basis/src/BasisParser.ts b/packages/basis/src/BasisParser.ts new file mode 100644 index 0000000000..b425269e69 --- /dev/null +++ b/packages/basis/src/BasisParser.ts @@ -0,0 +1,438 @@ +import { TYPES } from '@pixi/constants'; +import type { ExtensionMetadata } from '@pixi/core'; +import { BufferResource, ExtensionType } from '@pixi/core'; +import { CompressedTextureResource } from '@pixi/compressed-textures'; +import type { + BasisTextureExtensions, + BasisBinding } from './Basis'; +import { + BASIS_FORMATS, + BASIS_FORMAT_TO_INTERNAL_FORMAT, + INTERNAL_FORMAT_TO_BASIS_FORMAT, + BASIS_FORMATS_ALPHA +} from './Basis'; +import { TranscoderWorker } from './TranscoderWorker'; + +import type { CompressedLevelBuffer, INTERNAL_FORMATS } from '@pixi/compressed-textures'; + +export type TranscodedResourcesArray = (Array | Array) & { + basisFormat: BASIS_FORMATS +}; + +/** + * Loader plugin for handling BASIS supercompressed texture files. + * + * To use this loader, you must bind the basis_universal WebAssembly transcoder. There are two ways of + * doing this: + * + * 1. Adding a <script> tag to your HTML page to the transcoder bundle in this package, and serving + * the WASM binary from the same location. + * + * ```js + * // Copy ./node_modules/@pixi/basis/assets/basis_.wasm into your assets directory + * // as well, so it is served from the same folder as the JavaScript! + * <script src="./node_modules/@pixi/basis/assets/basis_transcoder.js" /> + * ``` + * + * NOTE: `basis_transcoder.js` expects the WebAssembly binary to be named `basis_transcoder.wasm`. + * NOTE-2: This method supports transcoding on the main-thread. Only use this if you have 1 or 2 *.basis + * files. + * + * 2. Loading the transcoder source from a URL. + * + * ```js + * // Use this if you to use the default CDN url for @pixi/basis + * BasisParser.loadTranscoder(); + * + * // Use this if you want to serve the transcoder on your own + * BasisParser.loadTranscoder('./basis_transcoder.js', './basis_transcoder.wasm'); + * ``` + * + * NOTE: This can only be used with web-workers. + * @class + * @memberof PIXI + * @implements {PIXI.ILoaderPlugin} + */ +export class BasisParser +{ + /** @ignore */ + static extension: ExtensionMetadata = ExtensionType.Loader; + + public static basisBinding: BasisBinding; + private static defaultRGBFormat: { basisFormat: BASIS_FORMATS, textureFormat: INTERNAL_FORMATS | TYPES }; + private static defaultRGBAFormat: { basisFormat: BASIS_FORMATS, textureFormat: INTERNAL_FORMATS | TYPES }; + private static fallbackMode = false; + private static workerPool: TranscoderWorker[] = []; + + /** + * Runs transcoding and populates {@link imageArray}. It will run the transcoding in a web worker + * if they are available. + * @private + */ + public static async transcode(arrayBuffer: ArrayBuffer): Promise + { + let resources: TranscodedResourcesArray; + + if (typeof Worker !== 'undefined' && BasisParser.TranscoderWorker.wasmSource) + { + resources = await BasisParser.transcodeAsync(arrayBuffer); + } + else + { + resources = BasisParser.transcodeSync(arrayBuffer); + } + + return resources; + } + + /** + * Finds a suitable worker for transcoding and sends a transcoding request + * @private + * @async + */ + public static async transcodeAsync(arrayBuffer: ArrayBuffer): Promise + { + const workerPool = BasisParser.workerPool; + + let leastLoad = 0x10000000; + let worker = null; + + for (let i = 0, j = workerPool.length; i < j; i++) + { + if (workerPool[i].load < leastLoad) + { + worker = workerPool[i]; + leastLoad = worker.load; + } + } + + if (!worker) + { + /* eslint-disable-next-line no-use-before-define */ + worker = new TranscoderWorker(); + + workerPool.push(worker); + } + + // Wait until worker is ready + await worker.initAsync(); + + const response = await worker.transcodeAsync( + new Uint8Array(arrayBuffer), + BasisParser.defaultRGBAFormat.basisFormat, + BasisParser.defaultRGBFormat.basisFormat, + ); + + const basisFormat = response.basisFormat; + const imageArray = response.imageArray; + + // whether it is an uncompressed format + const fallbackMode = basisFormat > 12; + let imageResources: TranscodedResourcesArray; + + if (!fallbackMode) + { + const format = BASIS_FORMAT_TO_INTERNAL_FORMAT[response.basisFormat]; + + // HINT: this.imageArray is CompressedTextureResource[] + imageResources = new Array(imageArray.length) as TranscodedResourcesArray; + + for (let i = 0, j = imageArray.length; i < j; i++) + { + imageResources[i] = new CompressedTextureResource(null, { + format, + width: imageArray[i].width, + height: imageArray[i].height, + levelBuffers: imageArray[i].levelArray, + levels: imageArray[i].levelArray.length, + }); + } + } + else + { + // TODO: BufferResource does not support manual mipmapping. + imageResources = imageArray.map((image) => new BufferResource( + new Uint16Array(image.levelArray[0].levelBuffer.buffer), { + width: image.width, + height: image.height, + }), + ) as TranscodedResourcesArray; + } + + imageResources.basisFormat = basisFormat; + + return imageResources; + } + + /** + * Runs transcoding on the main thread. + * @private + */ + public static transcodeSync(arrayBuffer: ArrayBuffer): TranscodedResourcesArray + { + const BASIS = BasisParser.basisBinding; + + const data = new Uint8Array(arrayBuffer); + const basisFile = new BASIS.BasisFile(data); + const imageCount = basisFile.getNumImages(); + const hasAlpha = basisFile.getHasAlpha(); + + const basisFormat = hasAlpha + ? BasisParser.defaultRGBAFormat.basisFormat + : BasisParser.defaultRGBFormat.basisFormat; + const basisFallbackFormat = BASIS_FORMATS.cTFRGB565; + const imageResources = new Array(imageCount); + + let fallbackMode = BasisParser.fallbackMode; + + if (!basisFile.startTranscoding()) + { + // #if _DEBUG + console.error(`Basis failed to start transcoding!`); + // #endif + + basisFile.close(); + basisFile.delete(); + + return null; + } + + for (let i = 0; i < imageCount; i++) + { + // Don't transcode all mipmap levels in fallback mode! + const levels = !fallbackMode ? basisFile.getNumLevels(i) : 1; + const width = basisFile.getImageWidth(i, 0); + const height = basisFile.getImageHeight(i, 0); + const alignedWidth = (width + 3) & ~3; + const alignedHeight = (height + 3) & ~3; + + const imageLevels = new Array(levels); + + // Transcode mipmap levels into "imageLevels" + for (let j = 0; j < levels; j++) + { + const levelWidth = basisFile.getImageWidth(i, j); + const levelHeight = basisFile.getImageHeight(i, j); + const byteSize = basisFile.getImageTranscodedSizeInBytes( + i, 0, !fallbackMode ? basisFormat : basisFallbackFormat); + + imageLevels[j] = { + levelID: j, + levelBuffer: new Uint8Array(byteSize), + levelWidth, + levelHeight, + }; + + if (!basisFile.transcodeImage( + imageLevels[j].levelBuffer, i, 0, !fallbackMode ? basisFormat : basisFallbackFormat, false, false)) + { + if (fallbackMode) + { + // #if _DEBUG + console.error(`Basis failed to transcode image ${i}, level ${0}!`); + // #endif + break; + } + else + { + // Try transcoding to an uncompressed format before giving up! + // NOTE: We must start all over again as all Resources must be in compressed OR uncompressed. + i = -1; + fallbackMode = true; + + // #if _DEBUG + /* eslint-disable-next-line max-len */ + console.warn(`Basis failed to transcode image ${i}, level ${0} to a compressed texture format. Retrying to an uncompressed fallback format!`); + // #endif + continue; + } + } + } + + let imageResource; + + if (!fallbackMode) + { + imageResource = new CompressedTextureResource(null, { + format: BASIS_FORMAT_TO_INTERNAL_FORMAT[basisFormat], + width: alignedWidth, + height: alignedHeight, + levelBuffers: imageLevels, + levels, + }); + } + else + { + // TODO: BufferResource doesn't support manual mipmap levels + imageResource = new BufferResource( + new Uint16Array(imageLevels[0].levelBuffer.buffer), { width, height }); + } + + imageResources[i] = imageResource; + } + + basisFile.close(); + basisFile.delete(); + + const transcodedResources = imageResources as TranscodedResourcesArray; + + transcodedResources.basisFormat = !fallbackMode ? basisFormat : basisFallbackFormat; + + return transcodedResources; + } + + /** + * Detects the available compressed texture formats on the device. + * @param extensions - extensions provided by a WebGL context + * @ignore + */ + static autoDetectFormats(extensions?: Partial): void + { + // Auto-detect WebGL compressed-texture extensions + if (!extensions) + { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl'); + + if (!gl) + { + console.error('WebGL not available for BASIS transcoding. Silently failing.'); + + return; + } + + extensions = { + s3tc: gl.getExtension('WEBGL_compressed_texture_s3tc'), + s3tc_sRGB: gl.getExtension('WEBGL_compressed_texture_s3tc_srgb'), /* eslint-disable-line camelcase */ + etc: gl.getExtension('WEBGL_compressed_texture_etc'), + etc1: gl.getExtension('WEBGL_compressed_texture_etc1'), + pvrtc: gl.getExtension('WEBGL_compressed_texture_pvrtc') + || gl.getExtension('WEBKIT_WEBGL_compressed_texture_pvrtc'), + atc: gl.getExtension('WEBGL_compressed_texture_atc'), + astc: gl.getExtension('WEBGL_compressed_texture_astc'), + }; + } + + // Discover the available texture formats + const supportedFormats: { [id: string]: INTERNAL_FORMATS } = {}; + + for (const key in extensions) + { + const extension = (extensions as any)[key]; + + if (!extension) + { + continue; + } + + Object.assign(supportedFormats, Object.getPrototypeOf(extension)); + } + + // Set the default alpha/non-alpha output formats for basisu transcoding + for (let i = 0; i < 2; i++) + { + const detectWithAlpha = !!i; + let internalFormat: number; + let basisFormat: number; + + for (const id in supportedFormats) + { + internalFormat = supportedFormats[id]; + basisFormat = INTERNAL_FORMAT_TO_BASIS_FORMAT[internalFormat]; + + if (basisFormat !== undefined) + { + if ((detectWithAlpha && BASIS_FORMATS_ALPHA[basisFormat]) + || (!detectWithAlpha && !BASIS_FORMATS_ALPHA[basisFormat])) + { + break; + } + } + } + + if (internalFormat) + { + BasisParser[detectWithAlpha ? 'defaultRGBAFormat' : 'defaultRGBFormat'] = { + textureFormat: internalFormat, + basisFormat, + }; + } + else + { + BasisParser[detectWithAlpha ? 'defaultRGBAFormat' : 'defaultRGBFormat'] = { + textureFormat: TYPES.UNSIGNED_SHORT_5_6_5, + basisFormat: BASIS_FORMATS.cTFRGB565, + }; + + BasisParser.fallbackMode = true; + } + } + } + + /** + * Binds the basis_universal transcoder to decompress *.basis files. You must initialize the transcoder library yourself. + * + * ```js + * import { BasisParser } from '@pixi/basis'; + * import { Loader } from '@pixi/loaders'; + * + * // window.BASIS() returns a Promise-like object + * window.BASIS().then((basisLibrary) => + * { + * // Initialize basis-library; otherwise, transcoded results maybe corrupt! + * basisLibrary.initializeBasis(); + * + * // Bind BasisParser to the transcoder + * BasisParser.bindTranscoder(basisLibrary); + * }); + * ``` + * @param basisLibrary - the initialized transcoder library + * @private + */ + static bindTranscoder(basisLibrary: BasisBinding): void + { + BasisParser.basisBinding = basisLibrary; + } + + /** + * Loads the transcoder source code for use in {@link PIXI.BasisParser.TranscoderWorker}. + * @private + * @param jsURL - URL to the javascript basis transcoder + * @param wasmURL - URL to the wasm basis transcoder + */ + static loadTranscoder(jsURL: string, wasmURL: string): Promise<[void, void]> + { + return BasisParser.TranscoderWorker.loadTranscoder(jsURL, wasmURL); + } + + /** + * Set the transcoder source code directly + * @private + * @param jsSource - source for the javascript basis transcoder + * @param wasmSource - source for the wasm basis transcoder + */ + static setTranscoder(jsSource: string, wasmSource: ArrayBuffer): void + { + BasisParser.TranscoderWorker.setTranscoder(jsSource, wasmSource); + } + + static TranscoderWorker: typeof TranscoderWorker = TranscoderWorker; + + static get TRANSCODER_WORKER_POOL_LIMIT(): number + { + return this.workerPool.length || 1; + } + + static set TRANSCODER_WORKER_POOL_LIMIT(limit: number) + { + // TODO: Destroy workers? + for (let i = this.workerPool.length; i < limit; i++) + { + this.workerPool[i] = new TranscoderWorker(); + this.workerPool[i].initAsync(); + } + } +} + +// Auto-detect compressed texture formats once @pixi/basis is imported! +BasisParser.autoDetectFormats(); diff --git a/packages/basis/src/TranscoderWorker.ts b/packages/basis/src/TranscoderWorker.ts index 7ee7189460..52dd362d7c 100644 --- a/packages/basis/src/TranscoderWorker.ts +++ b/packages/basis/src/TranscoderWorker.ts @@ -1,4 +1,3 @@ -import { Runner } from '@pixi/runner'; import type { BASIS_FORMATS } from './Basis'; import type { ITranscodeResponse } from './TranscoderWorkerWrapper'; import { TranscoderWorkerWrapper } from './TranscoderWorkerWrapper'; @@ -22,7 +21,13 @@ export class TranscoderWorker static jsSource: string; static wasmSource: ArrayBuffer; - public static onTranscoderInitialized = new Runner('onTranscoderInitialized'); + private static _onTranscoderInitializedResolve: () => void; + + /** a promise that when reslved means the transcoder is ready to be used */ + public static onTranscoderInitialized = new Promise((resolve) => + { + TranscoderWorker._onTranscoderInitializedResolve = resolve; + }); isInit: boolean; load: number; @@ -175,8 +180,9 @@ export class TranscoderWorker .then((arrayBuffer: ArrayBuffer) => { TranscoderWorker.wasmSource = arrayBuffer; }); return Promise.all([jsPromise, wasmPromise]).then((data) => + { - TranscoderWorker.onTranscoderInitialized.emit(); + this._onTranscoderInitializedResolve(); return data; }); diff --git a/packages/basis/src/index.ts b/packages/basis/src/index.ts index 1d20a0b567..8be6054983 100644 --- a/packages/basis/src/index.ts +++ b/packages/basis/src/index.ts @@ -3,5 +3,7 @@ import { BasisLoader } from './BasisLoader'; export * from './Basis'; export * from './BasisLoader'; +export * from './BasisParser'; +export * from './TranscoderWorker'; extensions.add(BasisLoader); diff --git a/packages/compressed-textures/src/index.ts b/packages/compressed-textures/src/index.ts index a7268776fb..ed9b174aa7 100644 --- a/packages/compressed-textures/src/index.ts +++ b/packages/compressed-textures/src/index.ts @@ -1,3 +1,4 @@ export * from './const'; export * from './resources'; export * from './loaders'; +export * from './parsers'; diff --git a/packages/compressed-textures/src/loaders/DDSLoader.ts b/packages/compressed-textures/src/loaders/DDSLoader.ts index ce073b9dd3..6506a341c6 100644 --- a/packages/compressed-textures/src/loaders/DDSLoader.ts +++ b/packages/compressed-textures/src/loaders/DDSLoader.ts @@ -1,251 +1,12 @@ -import { CompressedTextureResource } from '../resources'; -import { INTERNAL_FORMATS, INTERNAL_FORMAT_TO_BYTES_PER_PIXEL } from '../const'; import { LoaderResource } from '@pixi/loaders'; import { registerCompressedTextures } from './registerCompressedTextures'; import type { ExtensionMetadata } from '@pixi/core'; import { ExtensionType } from '@pixi/core'; +import { parseDDS } from '../parsers'; // Set DDS files to be loaded as an ArrayBuffer LoaderResource.setExtensionXhrType('dds', LoaderResource.XHR_RESPONSE_TYPE.BUFFER); -const DDS_MAGIC_SIZE = 4; -const DDS_HEADER_SIZE = 124; -const DDS_HEADER_PF_SIZE = 32; -const DDS_HEADER_DX10_SIZE = 20; - -// DDS file format magic word -const DDS_MAGIC = 0x20534444; - -/** - * DWORD offsets of the DDS file header fields (relative to file start). - * @ignore - */ -const DDS_FIELDS = { - SIZE: 1, - FLAGS: 2, - HEIGHT: 3, - WIDTH: 4, - MIPMAP_COUNT: 7, - PIXEL_FORMAT: 19, -}; - -/** - * DWORD offsets of the DDS PIXEL_FORMAT fields. - * @ignore - */ -const DDS_PF_FIELDS = { - SIZE: 0, - FLAGS: 1, - FOURCC: 2, - RGB_BITCOUNT: 3, - R_BIT_MASK: 4, - G_BIT_MASK: 5, - B_BIT_MASK: 6, - A_BIT_MASK: 7 -}; - -/** - * DWORD offsets of the DDS_HEADER_DX10 fields. - * @ignore - */ -const DDS_DX10_FIELDS = { - DXGI_FORMAT: 0, - RESOURCE_DIMENSION: 1, - MISC_FLAG: 2, - ARRAY_SIZE: 3, - MISC_FLAGS2: 4 -}; - -/** - * @see https://docs.microsoft.com/en-us/windows/win32/api/dxgiformat/ne-dxgiformat-dxgi_format - * @ignore - */ -// This is way over-blown for us! Lend us a hand, and remove the ones that aren't used (but set the remaining -// ones to their correct value) -enum DXGI_FORMAT - { - DXGI_FORMAT_UNKNOWN, - DXGI_FORMAT_R32G32B32A32_TYPELESS, - DXGI_FORMAT_R32G32B32A32_FLOAT, - DXGI_FORMAT_R32G32B32A32_UINT, - DXGI_FORMAT_R32G32B32A32_SINT, - DXGI_FORMAT_R32G32B32_TYPELESS, - DXGI_FORMAT_R32G32B32_FLOAT, - DXGI_FORMAT_R32G32B32_UINT, - DXGI_FORMAT_R32G32B32_SINT, - DXGI_FORMAT_R16G16B16A16_TYPELESS, - DXGI_FORMAT_R16G16B16A16_FLOAT, - DXGI_FORMAT_R16G16B16A16_UNORM, - DXGI_FORMAT_R16G16B16A16_UINT, - DXGI_FORMAT_R16G16B16A16_SNORM, - DXGI_FORMAT_R16G16B16A16_SINT, - DXGI_FORMAT_R32G32_TYPELESS, - DXGI_FORMAT_R32G32_FLOAT, - DXGI_FORMAT_R32G32_UINT, - DXGI_FORMAT_R32G32_SINT, - DXGI_FORMAT_R32G8X24_TYPELESS, - DXGI_FORMAT_D32_FLOAT_S8X24_UINT, - DXGI_FORMAT_R32_FLOAT_X8X24_TYPELESS, - DXGI_FORMAT_X32_TYPELESS_G8X24_UINT, - DXGI_FORMAT_R10G10B10A2_TYPELESS, - DXGI_FORMAT_R10G10B10A2_UNORM, - DXGI_FORMAT_R10G10B10A2_UINT, - DXGI_FORMAT_R11G11B10_FLOAT, - DXGI_FORMAT_R8G8B8A8_TYPELESS, - DXGI_FORMAT_R8G8B8A8_UNORM, - DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, - DXGI_FORMAT_R8G8B8A8_UINT, - DXGI_FORMAT_R8G8B8A8_SNORM, - DXGI_FORMAT_R8G8B8A8_SINT, - DXGI_FORMAT_R16G16_TYPELESS, - DXGI_FORMAT_R16G16_FLOAT, - DXGI_FORMAT_R16G16_UNORM, - DXGI_FORMAT_R16G16_UINT, - DXGI_FORMAT_R16G16_SNORM, - DXGI_FORMAT_R16G16_SINT, - DXGI_FORMAT_R32_TYPELESS, - DXGI_FORMAT_D32_FLOAT, - DXGI_FORMAT_R32_FLOAT, - DXGI_FORMAT_R32_UINT, - DXGI_FORMAT_R32_SINT, - DXGI_FORMAT_R24G8_TYPELESS, - DXGI_FORMAT_D24_UNORM_S8_UINT, - DXGI_FORMAT_R24_UNORM_X8_TYPELESS, - DXGI_FORMAT_X24_TYPELESS_G8_UINT, - DXGI_FORMAT_R8G8_TYPELESS, - DXGI_FORMAT_R8G8_UNORM, - DXGI_FORMAT_R8G8_UINT, - DXGI_FORMAT_R8G8_SNORM, - DXGI_FORMAT_R8G8_SINT, - DXGI_FORMAT_R16_TYPELESS, - DXGI_FORMAT_R16_FLOAT, - DXGI_FORMAT_D16_UNORM, - DXGI_FORMAT_R16_UNORM, - DXGI_FORMAT_R16_UINT, - DXGI_FORMAT_R16_SNORM, - DXGI_FORMAT_R16_SINT, - DXGI_FORMAT_R8_TYPELESS, - DXGI_FORMAT_R8_UNORM, - DXGI_FORMAT_R8_UINT, - DXGI_FORMAT_R8_SNORM, - DXGI_FORMAT_R8_SINT, - DXGI_FORMAT_A8_UNORM, - DXGI_FORMAT_R1_UNORM, - DXGI_FORMAT_R9G9B9E5_SHAREDEXP, - DXGI_FORMAT_R8G8_B8G8_UNORM, - DXGI_FORMAT_G8R8_G8B8_UNORM, - DXGI_FORMAT_BC1_TYPELESS, - DXGI_FORMAT_BC1_UNORM, - DXGI_FORMAT_BC1_UNORM_SRGB, - DXGI_FORMAT_BC2_TYPELESS, - DXGI_FORMAT_BC2_UNORM, - DXGI_FORMAT_BC2_UNORM_SRGB, - DXGI_FORMAT_BC3_TYPELESS, - DXGI_FORMAT_BC3_UNORM, - DXGI_FORMAT_BC3_UNORM_SRGB, - DXGI_FORMAT_BC4_TYPELESS, - DXGI_FORMAT_BC4_UNORM, - DXGI_FORMAT_BC4_SNORM, - DXGI_FORMAT_BC5_TYPELESS, - DXGI_FORMAT_BC5_UNORM, - DXGI_FORMAT_BC5_SNORM, - DXGI_FORMAT_B5G6R5_UNORM, - DXGI_FORMAT_B5G5R5A1_UNORM, - DXGI_FORMAT_B8G8R8A8_UNORM, - DXGI_FORMAT_B8G8R8X8_UNORM, - DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM, - DXGI_FORMAT_B8G8R8A8_TYPELESS, - DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, - DXGI_FORMAT_B8G8R8X8_TYPELESS, - DXGI_FORMAT_B8G8R8X8_UNORM_SRGB, - DXGI_FORMAT_BC6H_TYPELESS, - DXGI_FORMAT_BC6H_UF16, - DXGI_FORMAT_BC6H_SF16, - DXGI_FORMAT_BC7_TYPELESS, - DXGI_FORMAT_BC7_UNORM, - DXGI_FORMAT_BC7_UNORM_SRGB, - DXGI_FORMAT_AYUV, - DXGI_FORMAT_Y410, - DXGI_FORMAT_Y416, - DXGI_FORMAT_NV12, - DXGI_FORMAT_P010, - DXGI_FORMAT_P016, - DXGI_FORMAT_420_OPAQUE, - DXGI_FORMAT_YUY2, - DXGI_FORMAT_Y210, - DXGI_FORMAT_Y216, - DXGI_FORMAT_NV11, - DXGI_FORMAT_AI44, - DXGI_FORMAT_IA44, - DXGI_FORMAT_P8, - DXGI_FORMAT_A8P8, - DXGI_FORMAT_B4G4R4A4_UNORM, - DXGI_FORMAT_P208, - DXGI_FORMAT_V208, - DXGI_FORMAT_V408, - DXGI_FORMAT_SAMPLER_FEEDBACK_MIN_MIP_OPAQUE, - DXGI_FORMAT_SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE, - DXGI_FORMAT_FORCE_UINT -} - -/** - * Possible values of the field {@link DDS_DX10_FIELDS.RESOURCE_DIMENSION} - * @ignore - */ -enum D3D10_RESOURCE_DIMENSION - { - DDS_DIMENSION_TEXTURE1D = 2, - DDS_DIMENSION_TEXTURE2D = 3, - DDS_DIMENSION_TEXTURE3D = 6 -} - -const PF_FLAGS = 1; - -// PIXEL_FORMAT flags -const DDPF_ALPHA = 0x2; -const DDPF_FOURCC = 0x4; -const DDPF_RGB = 0x40; -const DDPF_YUV = 0x200; -const DDPF_LUMINANCE = 0x20000; - -// Four character codes for DXTn formats -const FOURCC_DXT1 = 0x31545844; -const FOURCC_DXT3 = 0x33545844; -const FOURCC_DXT5 = 0x35545844; -const FOURCC_DX10 = 0x30315844; - -// Cubemap texture flag (for DDS_DX10_FIELDS.MISC_FLAG) -const DDS_RESOURCE_MISC_TEXTURECUBE = 0x4; - -/** - * Maps `FOURCC_*` formats to internal formats (see {@link PIXI.INTERNAL_FORMATS}). - * @ignore - */ -const FOURCC_TO_FORMAT: { [id: number]: number } = { - [FOURCC_DXT1]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, - [FOURCC_DXT3]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, - [FOURCC_DXT5]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT -}; - -/** - * Maps {@link DXGI_FORMAT} to types/internal-formats (see {@link PIXI.TYPES}, {@link PIXI.INTERNAL_FORMATS}) - * @ignore - */ -const DXGI_TO_FORMAT: { [id: number]: number } = { - // WEBGL_compressed_texture_s3tc - [DXGI_FORMAT.DXGI_FORMAT_BC1_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC2_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC3_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT, - - // WEBGL_compressed_texture_s3tc_srgb - [DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT, - [DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT -}; - /** * @class * @memberof PIXI @@ -271,7 +32,7 @@ export class DDSLoader { Object.assign(resource, registerCompressedTextures( resource.name || resource.url, - DDSLoader.parse(resource.data), + parseDDS(resource.data), resource.metadata, )); } @@ -285,156 +46,4 @@ export class DDSLoader next(); } - - /** Parses the DDS file header, generates base-textures, and puts them into the texture cache. */ - private static parse(arrayBuffer: ArrayBuffer): CompressedTextureResource[] - { - const data = new Uint32Array(arrayBuffer); - const magicWord = data[0]; - - if (magicWord !== DDS_MAGIC) - { - throw new Error('Invalid DDS file magic word'); - } - - const header = new Uint32Array(arrayBuffer, 0, DDS_HEADER_SIZE / Uint32Array.BYTES_PER_ELEMENT); - - // DDS header fields - const height = header[DDS_FIELDS.HEIGHT]; - const width = header[DDS_FIELDS.WIDTH]; - const mipmapCount = header[DDS_FIELDS.MIPMAP_COUNT]; - - // PIXEL_FORMAT fields - const pixelFormat = new Uint32Array( - arrayBuffer, - DDS_FIELDS.PIXEL_FORMAT * Uint32Array.BYTES_PER_ELEMENT, - DDS_HEADER_PF_SIZE / Uint32Array.BYTES_PER_ELEMENT); - const formatFlags = pixelFormat[PF_FLAGS]; - - // File contains compressed texture(s) - if (formatFlags & DDPF_FOURCC) - { - const fourCC = pixelFormat[DDS_PF_FIELDS.FOURCC]; - - // File contains one DXTn compressed texture - if (fourCC !== FOURCC_DX10) - { - const internalFormat = FOURCC_TO_FORMAT[fourCC]; - - const dataOffset = DDS_MAGIC_SIZE + DDS_HEADER_SIZE; - const texData = new Uint8Array(arrayBuffer, dataOffset); - - const resource = new CompressedTextureResource(texData, { - format: internalFormat, - width, - height, - levels: mipmapCount // CompressedTextureResource will separate the levelBuffers for us! - }); - - return [resource]; - } - - // FOURCC_DX10 indicates there is a 20-byte DDS_HEADER_DX10 after DDS_HEADER - const dx10Offset = DDS_MAGIC_SIZE + DDS_HEADER_SIZE; - const dx10Header = new Uint32Array( - data.buffer, - dx10Offset, - DDS_HEADER_DX10_SIZE / Uint32Array.BYTES_PER_ELEMENT); - const dxgiFormat = dx10Header[DDS_DX10_FIELDS.DXGI_FORMAT]; - const resourceDimension = dx10Header[DDS_DX10_FIELDS.RESOURCE_DIMENSION]; - const miscFlag = dx10Header[DDS_DX10_FIELDS.MISC_FLAG]; - const arraySize = dx10Header[DDS_DX10_FIELDS.ARRAY_SIZE]; - - // Map dxgiFormat to PIXI.INTERNAL_FORMATS - const internalFormat = DXGI_TO_FORMAT[dxgiFormat]; - - if (internalFormat === undefined) - { - throw new Error(`DDSLoader cannot parse texture data with DXGI format ${dxgiFormat}`); - } - if (miscFlag === DDS_RESOURCE_MISC_TEXTURECUBE) - { - // FIXME: Anybody excited about cubemap compressed textures? - throw new Error('DDSLoader does not support cubemap textures'); - } - if (resourceDimension === D3D10_RESOURCE_DIMENSION.DDS_DIMENSION_TEXTURE3D) - { - // FIXME: Anybody excited about 3D compressed textures? - throw new Error('DDSLoader does not supported 3D texture data'); - } - - // Uint8Array buffers of image data, including all mipmap levels in each image - const imageBuffers = new Array(); - const dataOffset = DDS_MAGIC_SIZE - + DDS_HEADER_SIZE - + DDS_HEADER_DX10_SIZE; - - if (arraySize === 1) - { - // No need bothering with the imageSize calculation! - imageBuffers.push(new Uint8Array(arrayBuffer, dataOffset)); - } - else - { - // Calculate imageSize for each texture, and then locate each image's texture data - - const pixelSize = INTERNAL_FORMAT_TO_BYTES_PER_PIXEL[internalFormat]; - let imageSize = 0; - let levelWidth = width; - let levelHeight = height; - - for (let i = 0; i < mipmapCount; i++) - { - const alignedLevelWidth = Math.max(1, (levelWidth + 3) & ~3); - const alignedLevelHeight = Math.max(1, (levelHeight + 3) & ~3); - - const levelSize = alignedLevelWidth * alignedLevelHeight * pixelSize; - - imageSize += levelSize; - - levelWidth = levelWidth >>> 1; - levelHeight = levelHeight >>> 1; - } - - let imageOffset = dataOffset; - - // NOTE: Cubemaps have 6-images per texture (but they aren't supported so ^_^) - for (let i = 0; i < arraySize; i++) - { - imageBuffers.push(new Uint8Array(arrayBuffer, imageOffset, imageSize)); - imageOffset += imageSize; - } - } - - // Uint8Array -> CompressedTextureResource, and we're done! - return imageBuffers.map((buffer) => new CompressedTextureResource(buffer, { - format: internalFormat, - width, - height, - levels: mipmapCount - })); - } - if (formatFlags & DDPF_RGB) - { - // FIXME: We might want to allow uncompressed *.dds files? - throw new Error('DDSLoader does not support uncompressed texture data.'); - } - if (formatFlags & DDPF_YUV) - { - // FIXME: Does anybody need this feature? - throw new Error('DDSLoader does not supported YUV uncompressed texture data.'); - } - if (formatFlags & DDPF_LUMINANCE) - { - // FIXME: Microsoft says older DDS filers use this feature! Probably not worth the effort! - throw new Error('DDSLoader does not support single-channel (lumninance) texture data!'); - } - if (formatFlags & DDPF_ALPHA) - { - // FIXME: I'm tired! See above =) - throw new Error('DDSLoader does not support single-channel (alpha) texture data!'); - } - - throw new Error('DDSLoader failed to load a texture file due to an unknown reason!'); - } } diff --git a/packages/compressed-textures/src/loaders/KTXLoader.ts b/packages/compressed-textures/src/loaders/KTXLoader.ts index 5600d088a2..3bba514745 100644 --- a/packages/compressed-textures/src/loaders/KTXLoader.ts +++ b/packages/compressed-textures/src/loaders/KTXLoader.ts @@ -1,93 +1,13 @@ -import { ALPHA_MODES, FORMATS, MIPMAP_MODES, TYPES } from '@pixi/constants'; +import { ALPHA_MODES, MIPMAP_MODES } from '@pixi/constants'; import type { ExtensionMetadata } from '@pixi/core'; -import { BaseTexture, BufferResource, ExtensionType, Texture } from '@pixi/core'; -import type { CompressedLevelBuffer } from '../resources/CompressedTextureResource'; -import { CompressedTextureResource } from '../resources/CompressedTextureResource'; +import { BaseTexture, ExtensionType, Texture } from '@pixi/core'; import { LoaderResource } from '@pixi/loaders'; -import { INTERNAL_FORMAT_TO_BYTES_PER_PIXEL } from '../const'; import { registerCompressedTextures } from './registerCompressedTextures'; +import { parseKTX } from '../parsers'; // Set KTX files to be loaded as an ArrayBuffer LoaderResource.setExtensionXhrType('ktx', LoaderResource.XHR_RESPONSE_TYPE.BUFFER); -/** - * The 12-byte KTX file identifier - * @see https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/#2.1 - * @ignore - */ -const FILE_IDENTIFIER = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]; - -/** - * The value stored in the "endianness" field. - * @see https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/#2.2 - * @ignore - */ -const ENDIANNESS = 0x04030201; - -/** - * Byte offsets of the KTX file header fields - * @ignore - */ -const KTX_FIELDS = { - FILE_IDENTIFIER: 0, - ENDIANNESS: 12, - GL_TYPE: 16, - GL_TYPE_SIZE: 20, - GL_FORMAT: 24, - GL_INTERNAL_FORMAT: 28, - GL_BASE_INTERNAL_FORMAT: 32, - PIXEL_WIDTH: 36, - PIXEL_HEIGHT: 40, - PIXEL_DEPTH: 44, - NUMBER_OF_ARRAY_ELEMENTS: 48, - NUMBER_OF_FACES: 52, - NUMBER_OF_MIPMAP_LEVELS: 56, - BYTES_OF_KEY_VALUE_DATA: 60 -}; - -/** - * Byte size of the file header fields in {@code KTX_FIELDS} - * @ignore - */ -const FILE_HEADER_SIZE = 64; - -/** - * Maps {@link PIXI.TYPES} to the bytes taken per component, excluding those ones that are bit-fields. - * @ignore - */ -export const TYPES_TO_BYTES_PER_COMPONENT: { [id: number]: number } = { - [TYPES.UNSIGNED_BYTE]: 1, - [TYPES.UNSIGNED_SHORT]: 2, - [TYPES.INT]: 4, - [TYPES.UNSIGNED_INT]: 4, - [TYPES.FLOAT]: 4, - [TYPES.HALF_FLOAT]: 8 -}; - -/** - * Number of components in each {@link PIXI.FORMATS} - * @ignore - */ -export const FORMATS_TO_COMPONENTS: { [id: number]: number } = { - [FORMATS.RGBA]: 4, - [FORMATS.RGB]: 3, - [FORMATS.RG]: 2, - [FORMATS.RED]: 1, - [FORMATS.LUMINANCE]: 1, - [FORMATS.LUMINANCE_ALPHA]: 2, - [FORMATS.ALPHA]: 1 -}; - -/** - * Number of bytes per pixel in bit-field types in {@link PIXI.TYPES} - * @ignore - */ -export const TYPES_TO_BYTES_PER_PIXEL: { [id: number]: number } = { - [TYPES.UNSIGNED_SHORT_4_4_4_4]: 2, - [TYPES.UNSIGNED_SHORT_5_5_5_1]: 2, - [TYPES.UNSIGNED_SHORT_5_6_5]: 2 -}; - /** * Loader plugin for handling KTX texture container files. * @@ -136,7 +56,7 @@ export class KTXLoader try { const url = resource.name || resource.url; - const { compressed, uncompressed, kvData } = KTXLoader.parse(url, resource.data); + const { compressed, uncompressed, kvData } = parseKTX(url, resource.data, this.loadKeyValueData); if (compressed) { @@ -201,291 +121,4 @@ export class KTXLoader next(); } - - /** Parses the KTX file header, generates base-textures, and puts them into the texture cache. */ - private static parse(url: string, arrayBuffer: ArrayBuffer): { - compressed?: CompressedTextureResource[] - uncompressed?: { resource: BufferResource, type: TYPES, format: FORMATS }[] - kvData: Map | null - } - { - const dataView = new DataView(arrayBuffer); - - if (!KTXLoader.validate(url, dataView)) - { - return null; - } - - const littleEndian = dataView.getUint32(KTX_FIELDS.ENDIANNESS, true) === ENDIANNESS; - const glType = dataView.getUint32(KTX_FIELDS.GL_TYPE, littleEndian); - // const glTypeSize = dataView.getUint32(KTX_FIELDS.GL_TYPE_SIZE, littleEndian); - const glFormat = dataView.getUint32(KTX_FIELDS.GL_FORMAT, littleEndian); - const glInternalFormat = dataView.getUint32(KTX_FIELDS.GL_INTERNAL_FORMAT, littleEndian); - const pixelWidth = dataView.getUint32(KTX_FIELDS.PIXEL_WIDTH, littleEndian); - const pixelHeight = dataView.getUint32(KTX_FIELDS.PIXEL_HEIGHT, littleEndian) || 1;// "pixelHeight = 0" -> "1" - const pixelDepth = dataView.getUint32(KTX_FIELDS.PIXEL_DEPTH, littleEndian) || 1;// ^^ - const numberOfArrayElements = dataView.getUint32(KTX_FIELDS.NUMBER_OF_ARRAY_ELEMENTS, littleEndian) || 1;// ^^ - const numberOfFaces = dataView.getUint32(KTX_FIELDS.NUMBER_OF_FACES, littleEndian); - const numberOfMipmapLevels = dataView.getUint32(KTX_FIELDS.NUMBER_OF_MIPMAP_LEVELS, littleEndian); - const bytesOfKeyValueData = dataView.getUint32(KTX_FIELDS.BYTES_OF_KEY_VALUE_DATA, littleEndian); - - // Whether the platform architecture is little endian. If littleEndian !== platformLittleEndian, then the - // file contents must be endian-converted! - // TODO: Endianness conversion - // const platformLittleEndian = new Uint8Array((new Uint32Array([ENDIANNESS])).buffer)[0] === 0x01; - - if (pixelHeight === 0 || pixelDepth !== 1) - { - throw new Error('Only 2D textures are supported'); - } - if (numberOfFaces !== 1) - { - throw new Error('CubeTextures are not supported by KTXLoader yet!'); - } - if (numberOfArrayElements !== 1) - { - // TODO: Support splitting array-textures into multiple BaseTextures - throw new Error('WebGL does not support array textures'); - } - - // TODO: 8x4 blocks for 2bpp pvrtc - const blockWidth = 4; - const blockHeight = 4; - - const alignedWidth = (pixelWidth + 3) & ~3; - const alignedHeight = (pixelHeight + 3) & ~3; - const imageBuffers = new Array(numberOfArrayElements); - let imagePixels = pixelWidth * pixelHeight; - - if (glType === 0) - { - // Align to 16 pixels (4x4 blocks) - imagePixels = alignedWidth * alignedHeight; - } - - let imagePixelByteSize: number; - - if (glType !== 0) - { - // Uncompressed texture format - if (TYPES_TO_BYTES_PER_COMPONENT[glType]) - { - imagePixelByteSize = TYPES_TO_BYTES_PER_COMPONENT[glType] * FORMATS_TO_COMPONENTS[glFormat]; - } - else - { - imagePixelByteSize = TYPES_TO_BYTES_PER_PIXEL[glType]; - } - } - else - { - imagePixelByteSize = INTERNAL_FORMAT_TO_BYTES_PER_PIXEL[glInternalFormat]; - } - - if (imagePixelByteSize === undefined) - { - throw new Error('Unable to resolve the pixel format stored in the *.ktx file!'); - } - - const kvData: Map | null = KTXLoader.loadKeyValueData - ? KTXLoader.parseKvData(dataView, bytesOfKeyValueData, littleEndian) - : null; - - const imageByteSize = imagePixels * imagePixelByteSize; - let mipByteSize = imageByteSize; - let mipWidth = pixelWidth; - let mipHeight = pixelHeight; - let alignedMipWidth = alignedWidth; - let alignedMipHeight = alignedHeight; - let imageOffset = FILE_HEADER_SIZE + bytesOfKeyValueData; - - for (let mipmapLevel = 0; mipmapLevel < numberOfMipmapLevels; mipmapLevel++) - { - const imageSize = dataView.getUint32(imageOffset, littleEndian); - let elementOffset = imageOffset + 4; - - for (let arrayElement = 0; arrayElement < numberOfArrayElements; arrayElement++) - { - // TODO: Maybe support 3D textures? :-) - // for (let zSlice = 0; zSlice < pixelDepth; zSlice) - - let mips = imageBuffers[arrayElement]; - - if (!mips) - { - mips = imageBuffers[arrayElement] = new Array(numberOfMipmapLevels); - } - - mips[mipmapLevel] = { - levelID: mipmapLevel, - - // don't align mipWidth when texture not compressed! (glType not zero) - levelWidth: numberOfMipmapLevels > 1 || glType !== 0 ? mipWidth : alignedMipWidth, - levelHeight: numberOfMipmapLevels > 1 || glType !== 0 ? mipHeight : alignedMipHeight, - levelBuffer: new Uint8Array(arrayBuffer, elementOffset, mipByteSize) - }; - elementOffset += mipByteSize; - } - - // HINT: Aligns to 4-byte boundary after jumping imageSize (in lieu of mipPadding) - imageOffset += imageSize + 4;// (+4 to jump the imageSize field itself) - imageOffset = imageOffset % 4 !== 0 ? imageOffset + 4 - (imageOffset % 4) : imageOffset; - - // Calculate mipWidth, mipHeight for _next_ iteration - mipWidth = (mipWidth >> 1) || 1; - mipHeight = (mipHeight >> 1) || 1; - alignedMipWidth = (mipWidth + blockWidth - 1) & ~(blockWidth - 1); - alignedMipHeight = (mipHeight + blockHeight - 1) & ~(blockHeight - 1); - - // Each mipmap level is 4-times smaller? - mipByteSize = alignedMipWidth * alignedMipHeight * imagePixelByteSize; - } - - // We use the levelBuffers feature of CompressedTextureResource b/c texture data is image-major, not level-major. - if (glType !== 0) - { - return { - uncompressed: imageBuffers.map((levelBuffers) => - { - let buffer: Float32Array | Uint32Array | Int32Array | Uint8Array = levelBuffers[0].levelBuffer; - let convertToInt = false; - - if (glType === TYPES.FLOAT) - { - buffer = new Float32Array( - levelBuffers[0].levelBuffer.buffer, - levelBuffers[0].levelBuffer.byteOffset, - levelBuffers[0].levelBuffer.byteLength / 4); - } - else if (glType === TYPES.UNSIGNED_INT) - { - convertToInt = true; - buffer = new Uint32Array( - levelBuffers[0].levelBuffer.buffer, - levelBuffers[0].levelBuffer.byteOffset, - levelBuffers[0].levelBuffer.byteLength / 4); - } - else if (glType === TYPES.INT) - { - convertToInt = true; - buffer = new Int32Array( - levelBuffers[0].levelBuffer.buffer, - levelBuffers[0].levelBuffer.byteOffset, - levelBuffers[0].levelBuffer.byteLength / 4); - } - - return { - resource: new BufferResource( - buffer, - { - width: levelBuffers[0].levelWidth, - height: levelBuffers[0].levelHeight, - } - ), - type: glType, - format: convertToInt ? KTXLoader.convertFormatToInteger(glFormat) : glFormat, - }; - }), - kvData - }; - } - - return { - compressed: imageBuffers.map((levelBuffers) => new CompressedTextureResource(null, { - format: glInternalFormat, - width: pixelWidth, - height: pixelHeight, - levels: numberOfMipmapLevels, - levelBuffers, - })), - kvData - }; - } - - /** Checks whether the arrayBuffer contains a valid *.ktx file. */ - private static validate(url: string, dataView: DataView): boolean - { - // NOTE: Do not optimize this into 3 32-bit integer comparison because the endianness - // of the data is not specified. - for (let i = 0; i < FILE_IDENTIFIER.length; i++) - { - if (dataView.getUint8(i) !== FILE_IDENTIFIER[i]) - { - // #if _DEBUG - console.error(`${url} is not a valid *.ktx file!`); - // #endif - - return false; - } - } - - return true; - } - - private static convertFormatToInteger(format: FORMATS) - { - switch (format) - { - case FORMATS.RGBA: return FORMATS.RGBA_INTEGER; - case FORMATS.RGB: return FORMATS.RGB_INTEGER; - case FORMATS.RG: return FORMATS.RG_INTEGER; - case FORMATS.RED: return FORMATS.RED_INTEGER; - default: return format; - } - } - - private static parseKvData(dataView: DataView, bytesOfKeyValueData: number, littleEndian: boolean): Map - { - const kvData = new Map(); - let bytesIntoKeyValueData = 0; - - while (bytesIntoKeyValueData < bytesOfKeyValueData) - { - const keyAndValueByteSize = dataView.getUint32(FILE_HEADER_SIZE + bytesIntoKeyValueData, littleEndian); - const keyAndValueByteOffset = FILE_HEADER_SIZE + bytesIntoKeyValueData + 4; - const valuePadding = 3 - ((keyAndValueByteSize + 3) % 4); - - // Bounds check - if (keyAndValueByteSize === 0 || keyAndValueByteSize > bytesOfKeyValueData - bytesIntoKeyValueData) - { - console.error('KTXLoader: keyAndValueByteSize out of bounds'); - break; - } - - // Note: keyNulByte can't be 0 otherwise the key is an empty string. - let keyNulByte = 0; - - for (; keyNulByte < keyAndValueByteSize; keyNulByte++) - { - if (dataView.getUint8(keyAndValueByteOffset + keyNulByte) === 0x00) - { - break; - } - } - - if (keyNulByte === -1) - { - console.error('KTXLoader: Failed to find null byte terminating kvData key'); - break; - } - - const key = new TextDecoder().decode( - new Uint8Array(dataView.buffer, keyAndValueByteOffset, keyNulByte) - ); - const value = new DataView( - dataView.buffer, - keyAndValueByteOffset + keyNulByte + 1, - keyAndValueByteSize - keyNulByte - 1, - ); - - kvData.set(key, value); - - // 4 = the keyAndValueByteSize field itself - // keyAndValueByteSize = the bytes taken by the key and value - // valuePadding = extra padding to align with 4 bytes - bytesIntoKeyValueData += 4 + keyAndValueByteSize + valuePadding; - } - - return kvData; - } } diff --git a/packages/compressed-textures/src/parsers/index.ts b/packages/compressed-textures/src/parsers/index.ts new file mode 100644 index 0000000000..64586560a3 --- /dev/null +++ b/packages/compressed-textures/src/parsers/index.ts @@ -0,0 +1,3 @@ +export * from './parseDDS'; +export * from './parseKTX'; + diff --git a/packages/compressed-textures/src/parsers/parseDDS.ts b/packages/compressed-textures/src/parsers/parseDDS.ts new file mode 100644 index 0000000000..ec5b10e0ac --- /dev/null +++ b/packages/compressed-textures/src/parsers/parseDDS.ts @@ -0,0 +1,402 @@ +import { CompressedTextureResource } from '../resources'; +import { INTERNAL_FORMATS, INTERNAL_FORMAT_TO_BYTES_PER_PIXEL } from '../const'; + +const DDS_MAGIC_SIZE = 4; +const DDS_HEADER_SIZE = 124; +const DDS_HEADER_PF_SIZE = 32; +const DDS_HEADER_DX10_SIZE = 20; + +// DDS file format magic word +const DDS_MAGIC = 0x20534444; + +/** + * DWORD offsets of the DDS file header fields (relative to file start). + * @ignore + */ +const DDS_FIELDS = { + SIZE: 1, + FLAGS: 2, + HEIGHT: 3, + WIDTH: 4, + MIPMAP_COUNT: 7, + PIXEL_FORMAT: 19, +}; + +/** + * DWORD offsets of the DDS PIXEL_FORMAT fields. + * @ignore + */ +const DDS_PF_FIELDS = { + SIZE: 0, + FLAGS: 1, + FOURCC: 2, + RGB_BITCOUNT: 3, + R_BIT_MASK: 4, + G_BIT_MASK: 5, + B_BIT_MASK: 6, + A_BIT_MASK: 7 +}; + +/** + * DWORD offsets of the DDS_HEADER_DX10 fields. + * @ignore + */ +const DDS_DX10_FIELDS = { + DXGI_FORMAT: 0, + RESOURCE_DIMENSION: 1, + MISC_FLAG: 2, + ARRAY_SIZE: 3, + MISC_FLAGS2: 4 +}; + +/** + * @see https://docs.microsoft.com/en-us/windows/win32/api/dxgiformat/ne-dxgiformat-dxgi_format + * @ignore + */ +// This is way over-blown for us! Lend us a hand, and remove the ones that aren't used (but set the remaining +// ones to their correct value) +enum DXGI_FORMAT + { + DXGI_FORMAT_UNKNOWN, + DXGI_FORMAT_R32G32B32A32_TYPELESS, + DXGI_FORMAT_R32G32B32A32_FLOAT, + DXGI_FORMAT_R32G32B32A32_UINT, + DXGI_FORMAT_R32G32B32A32_SINT, + DXGI_FORMAT_R32G32B32_TYPELESS, + DXGI_FORMAT_R32G32B32_FLOAT, + DXGI_FORMAT_R32G32B32_UINT, + DXGI_FORMAT_R32G32B32_SINT, + DXGI_FORMAT_R16G16B16A16_TYPELESS, + DXGI_FORMAT_R16G16B16A16_FLOAT, + DXGI_FORMAT_R16G16B16A16_UNORM, + DXGI_FORMAT_R16G16B16A16_UINT, + DXGI_FORMAT_R16G16B16A16_SNORM, + DXGI_FORMAT_R16G16B16A16_SINT, + DXGI_FORMAT_R32G32_TYPELESS, + DXGI_FORMAT_R32G32_FLOAT, + DXGI_FORMAT_R32G32_UINT, + DXGI_FORMAT_R32G32_SINT, + DXGI_FORMAT_R32G8X24_TYPELESS, + DXGI_FORMAT_D32_FLOAT_S8X24_UINT, + DXGI_FORMAT_R32_FLOAT_X8X24_TYPELESS, + DXGI_FORMAT_X32_TYPELESS_G8X24_UINT, + DXGI_FORMAT_R10G10B10A2_TYPELESS, + DXGI_FORMAT_R10G10B10A2_UNORM, + DXGI_FORMAT_R10G10B10A2_UINT, + DXGI_FORMAT_R11G11B10_FLOAT, + DXGI_FORMAT_R8G8B8A8_TYPELESS, + DXGI_FORMAT_R8G8B8A8_UNORM, + DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, + DXGI_FORMAT_R8G8B8A8_UINT, + DXGI_FORMAT_R8G8B8A8_SNORM, + DXGI_FORMAT_R8G8B8A8_SINT, + DXGI_FORMAT_R16G16_TYPELESS, + DXGI_FORMAT_R16G16_FLOAT, + DXGI_FORMAT_R16G16_UNORM, + DXGI_FORMAT_R16G16_UINT, + DXGI_FORMAT_R16G16_SNORM, + DXGI_FORMAT_R16G16_SINT, + DXGI_FORMAT_R32_TYPELESS, + DXGI_FORMAT_D32_FLOAT, + DXGI_FORMAT_R32_FLOAT, + DXGI_FORMAT_R32_UINT, + DXGI_FORMAT_R32_SINT, + DXGI_FORMAT_R24G8_TYPELESS, + DXGI_FORMAT_D24_UNORM_S8_UINT, + DXGI_FORMAT_R24_UNORM_X8_TYPELESS, + DXGI_FORMAT_X24_TYPELESS_G8_UINT, + DXGI_FORMAT_R8G8_TYPELESS, + DXGI_FORMAT_R8G8_UNORM, + DXGI_FORMAT_R8G8_UINT, + DXGI_FORMAT_R8G8_SNORM, + DXGI_FORMAT_R8G8_SINT, + DXGI_FORMAT_R16_TYPELESS, + DXGI_FORMAT_R16_FLOAT, + DXGI_FORMAT_D16_UNORM, + DXGI_FORMAT_R16_UNORM, + DXGI_FORMAT_R16_UINT, + DXGI_FORMAT_R16_SNORM, + DXGI_FORMAT_R16_SINT, + DXGI_FORMAT_R8_TYPELESS, + DXGI_FORMAT_R8_UNORM, + DXGI_FORMAT_R8_UINT, + DXGI_FORMAT_R8_SNORM, + DXGI_FORMAT_R8_SINT, + DXGI_FORMAT_A8_UNORM, + DXGI_FORMAT_R1_UNORM, + DXGI_FORMAT_R9G9B9E5_SHAREDEXP, + DXGI_FORMAT_R8G8_B8G8_UNORM, + DXGI_FORMAT_G8R8_G8B8_UNORM, + DXGI_FORMAT_BC1_TYPELESS, + DXGI_FORMAT_BC1_UNORM, + DXGI_FORMAT_BC1_UNORM_SRGB, + DXGI_FORMAT_BC2_TYPELESS, + DXGI_FORMAT_BC2_UNORM, + DXGI_FORMAT_BC2_UNORM_SRGB, + DXGI_FORMAT_BC3_TYPELESS, + DXGI_FORMAT_BC3_UNORM, + DXGI_FORMAT_BC3_UNORM_SRGB, + DXGI_FORMAT_BC4_TYPELESS, + DXGI_FORMAT_BC4_UNORM, + DXGI_FORMAT_BC4_SNORM, + DXGI_FORMAT_BC5_TYPELESS, + DXGI_FORMAT_BC5_UNORM, + DXGI_FORMAT_BC5_SNORM, + DXGI_FORMAT_B5G6R5_UNORM, + DXGI_FORMAT_B5G5R5A1_UNORM, + DXGI_FORMAT_B8G8R8A8_UNORM, + DXGI_FORMAT_B8G8R8X8_UNORM, + DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM, + DXGI_FORMAT_B8G8R8A8_TYPELESS, + DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, + DXGI_FORMAT_B8G8R8X8_TYPELESS, + DXGI_FORMAT_B8G8R8X8_UNORM_SRGB, + DXGI_FORMAT_BC6H_TYPELESS, + DXGI_FORMAT_BC6H_UF16, + DXGI_FORMAT_BC6H_SF16, + DXGI_FORMAT_BC7_TYPELESS, + DXGI_FORMAT_BC7_UNORM, + DXGI_FORMAT_BC7_UNORM_SRGB, + DXGI_FORMAT_AYUV, + DXGI_FORMAT_Y410, + DXGI_FORMAT_Y416, + DXGI_FORMAT_NV12, + DXGI_FORMAT_P010, + DXGI_FORMAT_P016, + DXGI_FORMAT_420_OPAQUE, + DXGI_FORMAT_YUY2, + DXGI_FORMAT_Y210, + DXGI_FORMAT_Y216, + DXGI_FORMAT_NV11, + DXGI_FORMAT_AI44, + DXGI_FORMAT_IA44, + DXGI_FORMAT_P8, + DXGI_FORMAT_A8P8, + DXGI_FORMAT_B4G4R4A4_UNORM, + DXGI_FORMAT_P208, + DXGI_FORMAT_V208, + DXGI_FORMAT_V408, + DXGI_FORMAT_SAMPLER_FEEDBACK_MIN_MIP_OPAQUE, + DXGI_FORMAT_SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE, + DXGI_FORMAT_FORCE_UINT +} + +/** + * Possible values of the field {@link DDS_DX10_FIELDS.RESOURCE_DIMENSION} + * @ignore + */ +enum D3D10_RESOURCE_DIMENSION + { + DDS_DIMENSION_TEXTURE1D = 2, + DDS_DIMENSION_TEXTURE2D = 3, + DDS_DIMENSION_TEXTURE3D = 6 +} + +const PF_FLAGS = 1; + +// PIXEL_FORMAT flags +const DDPF_ALPHA = 0x2; +const DDPF_FOURCC = 0x4; +const DDPF_RGB = 0x40; +const DDPF_YUV = 0x200; +const DDPF_LUMINANCE = 0x20000; + +// Four character codes for DXTn formats +const FOURCC_DXT1 = 0x31545844; +const FOURCC_DXT3 = 0x33545844; +const FOURCC_DXT5 = 0x35545844; +const FOURCC_DX10 = 0x30315844; + +// Cubemap texture flag (for DDS_DX10_FIELDS.MISC_FLAG) +const DDS_RESOURCE_MISC_TEXTURECUBE = 0x4; + +/** + * Maps `FOURCC_*` formats to internal formats (see {@link PIXI.INTERNAL_FORMATS}). + * @ignore + */ +const FOURCC_TO_FORMAT: { [id: number]: number } = { + [FOURCC_DXT1]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, + [FOURCC_DXT3]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, + [FOURCC_DXT5]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT +}; + +/** + * Maps {@link DXGI_FORMAT} to types/internal-formats (see {@link PIXI.TYPES}, {@link PIXI.INTERNAL_FORMATS}) + * @ignore + */ +const DXGI_TO_FORMAT: { [id: number]: number } = { + // WEBGL_compressed_texture_s3tc + [DXGI_FORMAT.DXGI_FORMAT_BC1_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC2_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC3_TYPELESS]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM]: INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT, + + // WEBGL_compressed_texture_s3tc_srgb + [DXGI_FORMAT.DXGI_FORMAT_BC1_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC2_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT, + [DXGI_FORMAT.DXGI_FORMAT_BC3_UNORM_SRGB]: INTERNAL_FORMATS.COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT +}; + +/** + * @class + * @memberof PIXI + * @implements {PIXI.ILoaderPlugin} + * @see https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide + */ +/** + * Parses the DDS file header, generates base-textures, and puts them into the texture cache. + * @param arrayBuffer + */ +export function parseDDS(arrayBuffer: ArrayBuffer): CompressedTextureResource[] +{ + const data = new Uint32Array(arrayBuffer); + const magicWord = data[0]; + + if (magicWord !== DDS_MAGIC) + { + throw new Error('Invalid DDS file magic word'); + } + + const header = new Uint32Array(arrayBuffer, 0, DDS_HEADER_SIZE / Uint32Array.BYTES_PER_ELEMENT); + + // DDS header fields + const height = header[DDS_FIELDS.HEIGHT]; + const width = header[DDS_FIELDS.WIDTH]; + const mipmapCount = header[DDS_FIELDS.MIPMAP_COUNT]; + + // PIXEL_FORMAT fields + const pixelFormat = new Uint32Array( + arrayBuffer, + DDS_FIELDS.PIXEL_FORMAT * Uint32Array.BYTES_PER_ELEMENT, + DDS_HEADER_PF_SIZE / Uint32Array.BYTES_PER_ELEMENT); + const formatFlags = pixelFormat[PF_FLAGS]; + + // File contains compressed texture(s) + if (formatFlags & DDPF_FOURCC) + { + const fourCC = pixelFormat[DDS_PF_FIELDS.FOURCC]; + + // File contains one DXTn compressed texture + if (fourCC !== FOURCC_DX10) + { + const internalFormat = FOURCC_TO_FORMAT[fourCC]; + + const dataOffset = DDS_MAGIC_SIZE + DDS_HEADER_SIZE; + const texData = new Uint8Array(arrayBuffer, dataOffset); + + const resource = new CompressedTextureResource(texData, { + format: internalFormat, + width, + height, + levels: mipmapCount // CompressedTextureResource will separate the levelBuffers for us! + }); + + return [resource]; + } + + // FOURCC_DX10 indicates there is a 20-byte DDS_HEADER_DX10 after DDS_HEADER + const dx10Offset = DDS_MAGIC_SIZE + DDS_HEADER_SIZE; + const dx10Header = new Uint32Array( + data.buffer, + dx10Offset, + DDS_HEADER_DX10_SIZE / Uint32Array.BYTES_PER_ELEMENT); + const dxgiFormat = dx10Header[DDS_DX10_FIELDS.DXGI_FORMAT]; + const resourceDimension = dx10Header[DDS_DX10_FIELDS.RESOURCE_DIMENSION]; + const miscFlag = dx10Header[DDS_DX10_FIELDS.MISC_FLAG]; + const arraySize = dx10Header[DDS_DX10_FIELDS.ARRAY_SIZE]; + + // Map dxgiFormat to PIXI.INTERNAL_FORMATS + const internalFormat = DXGI_TO_FORMAT[dxgiFormat]; + + if (internalFormat === undefined) + { + throw new Error(`DDSParser cannot parse texture data with DXGI format ${dxgiFormat}`); + } + if (miscFlag === DDS_RESOURCE_MISC_TEXTURECUBE) + { + // FIXME: Anybody excited about cubemap compressed textures? + throw new Error('DDSParser does not support cubemap textures'); + } + if (resourceDimension === D3D10_RESOURCE_DIMENSION.DDS_DIMENSION_TEXTURE3D) + { + // FIXME: Anybody excited about 3D compressed textures? + throw new Error('DDSParser does not supported 3D texture data'); + } + + // Uint8Array buffers of image data, including all mipmap levels in each image + const imageBuffers = new Array(); + const dataOffset = DDS_MAGIC_SIZE + + DDS_HEADER_SIZE + + DDS_HEADER_DX10_SIZE; + + if (arraySize === 1) + { + // No need bothering with the imageSize calculation! + imageBuffers.push(new Uint8Array(arrayBuffer, dataOffset)); + } + else + { + // Calculate imageSize for each texture, and then locate each image's texture data + + const pixelSize = INTERNAL_FORMAT_TO_BYTES_PER_PIXEL[internalFormat]; + let imageSize = 0; + let levelWidth = width; + let levelHeight = height; + + for (let i = 0; i < mipmapCount; i++) + { + const alignedLevelWidth = Math.max(1, (levelWidth + 3) & ~3); + const alignedLevelHeight = Math.max(1, (levelHeight + 3) & ~3); + + const levelSize = alignedLevelWidth * alignedLevelHeight * pixelSize; + + imageSize += levelSize; + + levelWidth = levelWidth >>> 1; + levelHeight = levelHeight >>> 1; + } + + let imageOffset = dataOffset; + + // NOTE: Cubemaps have 6-images per texture (but they aren't supported so ^_^) + for (let i = 0; i < arraySize; i++) + { + imageBuffers.push(new Uint8Array(arrayBuffer, imageOffset, imageSize)); + imageOffset += imageSize; + } + } + + // Uint8Array -> CompressedTextureResource, and we're done! + return imageBuffers.map((buffer) => new CompressedTextureResource(buffer, { + format: internalFormat, + width, + height, + levels: mipmapCount + })); + } + if (formatFlags & DDPF_RGB) + { + // FIXME: We might want to allow uncompressed *.dds files? + throw new Error('DDSParser does not support uncompressed texture data.'); + } + if (formatFlags & DDPF_YUV) + { + // FIXME: Does anybody need this feature? + throw new Error('DDSParser does not supported YUV uncompressed texture data.'); + } + if (formatFlags & DDPF_LUMINANCE) + { + // FIXME: Microsoft says older DDS filers use this feature! Probably not worth the effort! + throw new Error('DDSParser does not support single-channel (lumninance) texture data!'); + } + if (formatFlags & DDPF_ALPHA) + { + // FIXME: I'm tired! See above =) + throw new Error('DDSParser does not support single-channel (alpha) texture data!'); + } + + throw new Error('DDSParser failed to load a texture file due to an unknown reason!'); +} + diff --git a/packages/compressed-textures/src/parsers/parseKTX.ts b/packages/compressed-textures/src/parsers/parseKTX.ts new file mode 100644 index 0000000000..9d11979996 --- /dev/null +++ b/packages/compressed-textures/src/parsers/parseKTX.ts @@ -0,0 +1,374 @@ +import { FORMATS, TYPES } from '@pixi/constants'; +import { BufferResource } from '@pixi/core'; + +import { INTERNAL_FORMAT_TO_BYTES_PER_PIXEL } from '../const'; +import type { CompressedLevelBuffer } from '../resources'; +import { CompressedTextureResource } from '../resources'; + +/** + * The 12-byte KTX file identifier + * @see https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/#2.1 + * @ignore + */ +const FILE_IDENTIFIER = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]; + +/** + * The value stored in the "endianness" field. + * @see https://www.khronos.org/opengles/sdk/tools/KTX/file_format_spec/#2.2 + * @ignore + */ +const ENDIANNESS = 0x04030201; + +/** + * Byte offsets of the KTX file header fields + * @ignore + */ +const KTX_FIELDS = { + FILE_IDENTIFIER: 0, + ENDIANNESS: 12, + GL_TYPE: 16, + GL_TYPE_SIZE: 20, + GL_FORMAT: 24, + GL_INTERNAL_FORMAT: 28, + GL_BASE_INTERNAL_FORMAT: 32, + PIXEL_WIDTH: 36, + PIXEL_HEIGHT: 40, + PIXEL_DEPTH: 44, + NUMBER_OF_ARRAY_ELEMENTS: 48, + NUMBER_OF_FACES: 52, + NUMBER_OF_MIPMAP_LEVELS: 56, + BYTES_OF_KEY_VALUE_DATA: 60 +}; + +/** + * Byte size of the file header fields in {@code KTX_FIELDS} + * @ignore + */ +const FILE_HEADER_SIZE = 64; + +/** + * Maps {@link PIXI.TYPES} to the bytes taken per component, excluding those ones that are bit-fields. + * @ignore + */ +export const TYPES_TO_BYTES_PER_COMPONENT: { [id: number]: number } = { + [TYPES.UNSIGNED_BYTE]: 1, + [TYPES.UNSIGNED_SHORT]: 2, + [TYPES.INT]: 4, + [TYPES.UNSIGNED_INT]: 4, + [TYPES.FLOAT]: 4, + [TYPES.HALF_FLOAT]: 8 +}; + +/** + * Number of components in each {@link PIXI.FORMATS} + * @ignore + */ +export const FORMATS_TO_COMPONENTS: { [id: number]: number } = { + [FORMATS.RGBA]: 4, + [FORMATS.RGB]: 3, + [FORMATS.RG]: 2, + [FORMATS.RED]: 1, + [FORMATS.LUMINANCE]: 1, + [FORMATS.LUMINANCE_ALPHA]: 2, + [FORMATS.ALPHA]: 1 +}; + +/** + * Number of bytes per pixel in bit-field types in {@link PIXI.TYPES} + * @ignore + */ +export const TYPES_TO_BYTES_PER_PIXEL: { [id: number]: number } = { + [TYPES.UNSIGNED_SHORT_4_4_4_4]: 2, + [TYPES.UNSIGNED_SHORT_5_5_5_1]: 2, + [TYPES.UNSIGNED_SHORT_5_6_5]: 2 +}; + +export function parseKTX(url: string, arrayBuffer: ArrayBuffer, loadKeyValueData = false): { + compressed?: CompressedTextureResource[] + uncompressed?: { resource: BufferResource, type: TYPES, format: FORMATS }[] + kvData: Map | null +} +{ + const dataView = new DataView(arrayBuffer); + + if (!validate(url, dataView)) + { + return null; + } + + const littleEndian = dataView.getUint32(KTX_FIELDS.ENDIANNESS, true) === ENDIANNESS; + const glType = dataView.getUint32(KTX_FIELDS.GL_TYPE, littleEndian); + // const glTypeSize = dataView.getUint32(KTX_FIELDS.GL_TYPE_SIZE, littleEndian); + const glFormat = dataView.getUint32(KTX_FIELDS.GL_FORMAT, littleEndian); + const glInternalFormat = dataView.getUint32(KTX_FIELDS.GL_INTERNAL_FORMAT, littleEndian); + const pixelWidth = dataView.getUint32(KTX_FIELDS.PIXEL_WIDTH, littleEndian); + const pixelHeight = dataView.getUint32(KTX_FIELDS.PIXEL_HEIGHT, littleEndian) || 1;// "pixelHeight = 0" -> "1" + const pixelDepth = dataView.getUint32(KTX_FIELDS.PIXEL_DEPTH, littleEndian) || 1;// ^^ + const numberOfArrayElements = dataView.getUint32(KTX_FIELDS.NUMBER_OF_ARRAY_ELEMENTS, littleEndian) || 1;// ^^ + const numberOfFaces = dataView.getUint32(KTX_FIELDS.NUMBER_OF_FACES, littleEndian); + const numberOfMipmapLevels = dataView.getUint32(KTX_FIELDS.NUMBER_OF_MIPMAP_LEVELS, littleEndian); + const bytesOfKeyValueData = dataView.getUint32(KTX_FIELDS.BYTES_OF_KEY_VALUE_DATA, littleEndian); + + // Whether the platform architecture is little endian. If littleEndian !== platformLittleEndian, then the + // file contents must be endian-converted! + // TODO: Endianness conversion + // const platformLittleEndian = new Uint8Array((new Uint32Array([ENDIANNESS])).buffer)[0] === 0x01; + + if (pixelHeight === 0 || pixelDepth !== 1) + { + throw new Error('Only 2D textures are supported'); + } + if (numberOfFaces !== 1) + { + throw new Error('CubeTextures are not supported by KTXLoader yet!'); + } + if (numberOfArrayElements !== 1) + { + // TODO: Support splitting array-textures into multiple BaseTextures + throw new Error('WebGL does not support array textures'); + } + + // TODO: 8x4 blocks for 2bpp pvrtc + const blockWidth = 4; + const blockHeight = 4; + + const alignedWidth = (pixelWidth + 3) & ~3; + const alignedHeight = (pixelHeight + 3) & ~3; + const imageBuffers = new Array(numberOfArrayElements); + let imagePixels = pixelWidth * pixelHeight; + + if (glType === 0) + { + // Align to 16 pixels (4x4 blocks) + imagePixels = alignedWidth * alignedHeight; + } + + let imagePixelByteSize: number; + + if (glType !== 0) + { + // Uncompressed texture format + if (TYPES_TO_BYTES_PER_COMPONENT[glType]) + { + imagePixelByteSize = TYPES_TO_BYTES_PER_COMPONENT[glType] * FORMATS_TO_COMPONENTS[glFormat]; + } + else + { + imagePixelByteSize = TYPES_TO_BYTES_PER_PIXEL[glType]; + } + } + else + { + imagePixelByteSize = INTERNAL_FORMAT_TO_BYTES_PER_PIXEL[glInternalFormat]; + } + + if (imagePixelByteSize === undefined) + { + throw new Error('Unable to resolve the pixel format stored in the *.ktx file!'); + } + + const kvData: Map | null = loadKeyValueData + ? parseKvData(dataView, bytesOfKeyValueData, littleEndian) + : null; + + const imageByteSize = imagePixels * imagePixelByteSize; + let mipByteSize = imageByteSize; + let mipWidth = pixelWidth; + let mipHeight = pixelHeight; + let alignedMipWidth = alignedWidth; + let alignedMipHeight = alignedHeight; + let imageOffset = FILE_HEADER_SIZE + bytesOfKeyValueData; + + for (let mipmapLevel = 0; mipmapLevel < numberOfMipmapLevels; mipmapLevel++) + { + const imageSize = dataView.getUint32(imageOffset, littleEndian); + let elementOffset = imageOffset + 4; + + for (let arrayElement = 0; arrayElement < numberOfArrayElements; arrayElement++) + { + // TODO: Maybe support 3D textures? :-) + // for (let zSlice = 0; zSlice < pixelDepth; zSlice) + + let mips = imageBuffers[arrayElement]; + + if (!mips) + { + mips = imageBuffers[arrayElement] = new Array(numberOfMipmapLevels); + } + + mips[mipmapLevel] = { + levelID: mipmapLevel, + + // don't align mipWidth when texture not compressed! (glType not zero) + levelWidth: numberOfMipmapLevels > 1 || glType !== 0 ? mipWidth : alignedMipWidth, + levelHeight: numberOfMipmapLevels > 1 || glType !== 0 ? mipHeight : alignedMipHeight, + levelBuffer: new Uint8Array(arrayBuffer, elementOffset, mipByteSize) + }; + elementOffset += mipByteSize; + } + + // HINT: Aligns to 4-byte boundary after jumping imageSize (in lieu of mipPadding) + imageOffset += imageSize + 4;// (+4 to jump the imageSize field itself) + imageOffset = imageOffset % 4 !== 0 ? imageOffset + 4 - (imageOffset % 4) : imageOffset; + + // Calculate mipWidth, mipHeight for _next_ iteration + mipWidth = (mipWidth >> 1) || 1; + mipHeight = (mipHeight >> 1) || 1; + alignedMipWidth = (mipWidth + blockWidth - 1) & ~(blockWidth - 1); + alignedMipHeight = (mipHeight + blockHeight - 1) & ~(blockHeight - 1); + + // Each mipmap level is 4-times smaller? + mipByteSize = alignedMipWidth * alignedMipHeight * imagePixelByteSize; + } + + // We use the levelBuffers feature of CompressedTextureResource b/c texture data is image-major, not level-major. + if (glType !== 0) + { + return { + uncompressed: imageBuffers.map((levelBuffers) => + { + let buffer: Float32Array | Uint32Array | Int32Array | Uint8Array = levelBuffers[0].levelBuffer; + let convertToInt = false; + + if (glType === TYPES.FLOAT) + { + buffer = new Float32Array( + levelBuffers[0].levelBuffer.buffer, + levelBuffers[0].levelBuffer.byteOffset, + levelBuffers[0].levelBuffer.byteLength / 4); + } + else if (glType === TYPES.UNSIGNED_INT) + { + convertToInt = true; + buffer = new Uint32Array( + levelBuffers[0].levelBuffer.buffer, + levelBuffers[0].levelBuffer.byteOffset, + levelBuffers[0].levelBuffer.byteLength / 4); + } + else if (glType === TYPES.INT) + { + convertToInt = true; + buffer = new Int32Array( + levelBuffers[0].levelBuffer.buffer, + levelBuffers[0].levelBuffer.byteOffset, + levelBuffers[0].levelBuffer.byteLength / 4); + } + + return { + resource: new BufferResource( + buffer, + { + width: levelBuffers[0].levelWidth, + height: levelBuffers[0].levelHeight, + } + ), + type: glType, + format: convertToInt ? convertFormatToInteger(glFormat) : glFormat, + }; + }), + kvData + }; + } + + return { + compressed: imageBuffers.map((levelBuffers) => new CompressedTextureResource(null, { + format: glInternalFormat, + width: pixelWidth, + height: pixelHeight, + levels: numberOfMipmapLevels, + levelBuffers, + })), + kvData + }; +} + +/** + * Checks whether the arrayBuffer contains a valid *.ktx file. + * @param url + * @param dataView + */ +function validate(url: string, dataView: DataView): boolean +{ + // NOTE: Do not optimize this into 3 32-bit integer comparison because the endianness + // of the data is not specified. + for (let i = 0; i < FILE_IDENTIFIER.length; i++) + { + if (dataView.getUint8(i) !== FILE_IDENTIFIER[i]) + { + // #if _DEBUG + console.error(`${url} is not a valid *.ktx file!`); + // #endif + + return false; + } + } + + return true; +} + +function convertFormatToInteger(format: FORMATS) +{ + switch (format) + { + case FORMATS.RGBA: return FORMATS.RGBA_INTEGER; + case FORMATS.RGB: return FORMATS.RGB_INTEGER; + case FORMATS.RG: return FORMATS.RG_INTEGER; + case FORMATS.RED: return FORMATS.RED_INTEGER; + default: return format; + } +} + +function parseKvData(dataView: DataView, bytesOfKeyValueData: number, littleEndian: boolean): Map +{ + const kvData = new Map(); + let bytesIntoKeyValueData = 0; + + while (bytesIntoKeyValueData < bytesOfKeyValueData) + { + const keyAndValueByteSize = dataView.getUint32(FILE_HEADER_SIZE + bytesIntoKeyValueData, littleEndian); + const keyAndValueByteOffset = FILE_HEADER_SIZE + bytesIntoKeyValueData + 4; + const valuePadding = 3 - ((keyAndValueByteSize + 3) % 4); + + // Bounds check + if (keyAndValueByteSize === 0 || keyAndValueByteSize > bytesOfKeyValueData - bytesIntoKeyValueData) + { + console.error('KTXLoader: keyAndValueByteSize out of bounds'); + break; + } + + // Note: keyNulByte can't be 0 otherwise the key is an empty string. + let keyNulByte = 0; + + for (; keyNulByte < keyAndValueByteSize; keyNulByte++) + { + if (dataView.getUint8(keyAndValueByteOffset + keyNulByte) === 0x00) + { + break; + } + } + + if (keyNulByte === -1) + { + console.error('KTXLoader: Failed to find null byte terminating kvData key'); + break; + } + + const key = new TextDecoder().decode( + new Uint8Array(dataView.buffer, keyAndValueByteOffset, keyNulByte) + ); + const value = new DataView( + dataView.buffer, + keyAndValueByteOffset + keyNulByte + 1, + keyAndValueByteSize - keyNulByte - 1, + ); + + kvData.set(key, value); + + // 4 = the keyAndValueByteSize field itself + // keyAndValueByteSize = the bytes taken by the key and value + // valuePadding = extra padding to align with 4 bytes + bytesIntoKeyValueData += 4 + keyAndValueByteSize + valuePadding; + } + + return kvData; +} diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts index 6b38b0964e..7820a12b2f 100644 --- a/packages/core/src/extensions.ts +++ b/packages/core/src/extensions.ts @@ -13,6 +13,9 @@ enum ExtensionType RendererPlugin = 'renderer-webgl-plugin', CanvasRendererPlugin = 'renderer-canvas-plugin', Loader = 'loader', + LoadParser = 'load-parser', + ResolveParser = 'resolve-parser', + CacheParser = 'cache-parser', } interface ExtensionMetadataDetails @@ -57,8 +60,8 @@ type ExtensionHandler = (extension: ExtensionFormat) => void; */ const normalizeExtension = (ext: ExtensionFormatLoose | any): ExtensionFormat => { - // Class submission, use extension object - if (typeof ext === 'function') + // Class/Object submission, use extension object + if (typeof ext === 'function' || (typeof ext === 'object' && ext.extension)) { // #if _DEBUG if (!ext.extension) diff --git a/packages/core/src/textures/resources/Resource.ts b/packages/core/src/textures/resources/Resource.ts index 9342b0391a..fc206c2366 100644 --- a/packages/core/src/textures/resources/Resource.ts +++ b/packages/core/src/textures/resources/Resource.ts @@ -12,6 +12,9 @@ import type { GLTexture } from '../GLTexture'; */ export abstract class Resource { + /** The url of the resource */ + public src: string; + /** * If resource has been destroyed. * @readonly diff --git a/packages/loaders/src/Loader.ts b/packages/loaders/src/Loader.ts index 5f6151cfbc..7c18081c30 100644 --- a/packages/loaders/src/Loader.ts +++ b/packages/loaders/src/Loader.ts @@ -390,6 +390,10 @@ class Loader */ load(cb?: Loader.OnCompleteSignal): this { + // #if _DEBUG + deprecation('6.5.0', '@pixi/loaders is being replaced with @pixi/assets in the next major release.'); + // #endif + // register complete callback if they pass one if (typeof cb === 'function') { diff --git a/packages/spritesheet/src/Spritesheet.ts b/packages/spritesheet/src/Spritesheet.ts index 47148c746a..1dd647e913 100644 --- a/packages/spritesheet/src/Spritesheet.ts +++ b/packages/spritesheet/src/Spritesheet.ts @@ -34,6 +34,8 @@ export interface ISpritesheetData animations?: Dict; meta: { scale: string; + // eslint-disable-next-line camelcase + related_multi_packs?: string[]; }; } @@ -72,6 +74,9 @@ export class Spritesheet /** The maximum number of Textures to build per process. */ static readonly BATCH_SIZE = 1000; + /** For multi-packed spritesheets, this contains a reference to all the other spritesheets it depends on. */ + public linkedSheets: Spritesheet[] = []; + /** Reference to ths source texture. */ public baseTexture: BaseTexture; @@ -371,6 +376,7 @@ export class Spritesheet } this._texture = null; this.baseTexture = null; + this.linkedSheets = []; } } diff --git a/packages/text-bitmap/src/formats/index.ts b/packages/text-bitmap/src/formats/index.ts index 5514ce27ec..4603462215 100644 --- a/packages/text-bitmap/src/formats/index.ts +++ b/packages/text-bitmap/src/formats/index.ts @@ -27,3 +27,5 @@ export function autoDetectFormat(data: unknown): typeof formats[number] | null return null; } + +export { TextFormat, XMLFormat, XMLStringFormat }; diff --git a/packages/text-bitmap/src/index.ts b/packages/text-bitmap/src/index.ts index b008c048ea..cdb8cee1f7 100644 --- a/packages/text-bitmap/src/index.ts +++ b/packages/text-bitmap/src/index.ts @@ -3,3 +3,4 @@ export * from './BitmapFontLoader'; export * from './BitmapFont'; export * from './BitmapFontData'; export * from './BitmapTextStyle'; +export * from './formats'; diff --git a/test/jest-global-setup.ts b/test/jest-global-setup.ts new file mode 100644 index 0000000000..bfa8b0f8d8 --- /dev/null +++ b/test/jest-global-setup.ts @@ -0,0 +1,19 @@ +import { exec } from 'child_process'; +import path from 'path'; + +// eslint-disable-next-line func-names +module.exports = async function () +{ + if (!process.env.GITHUB_ACTIONS) + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globalThis.__SERVER__ = exec(`http-server -c-1 ${path.join(process.cwd(), './packages')}`); + } + + await new Promise((resolve) => + { + // give the server time to start + setTimeout(resolve, 1000); + }); +}; diff --git a/test/jest-global-teardown.ts b/test/jest-global-teardown.ts new file mode 100644 index 0000000000..5dc1e4e748 --- /dev/null +++ b/test/jest-global-teardown.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line func-names +module.exports = async function () +{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globalThis.__SERVER__?.kill(); +};