diff --git a/packages/examples/public/assets/waterOverworld/image/Floor Tiles1.webp b/packages/examples/public/assets/waterOverworld/image/Floor Tiles1.webp new file mode 100644 index 000000000..a796368d7 Binary files /dev/null and b/packages/examples/public/assets/waterOverworld/image/Floor Tiles1.webp differ diff --git a/packages/examples/public/assets/waterOverworld/image/texture_image_0.webp b/packages/examples/public/assets/waterOverworld/image/texture_image_0.webp new file mode 100644 index 000000000..75ddd3ff6 Binary files /dev/null and b/packages/examples/public/assets/waterOverworld/image/texture_image_0.webp differ diff --git a/packages/examples/public/assets/waterOverworld/json/texture_image_0.json b/packages/examples/public/assets/waterOverworld/json/texture_image_0.json new file mode 100644 index 000000000..df81c6ae8 --- /dev/null +++ b/packages/examples/public/assets/waterOverworld/json/texture_image_0.json @@ -0,0 +1,165 @@ +{"frames": [ + +{ + "filename": "2dWater", + "frame": {"x":743,"y":387,"w":480,"h":480}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":480,"h":480}, + "sourceSize": {"w":480,"h":480}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Angel Statue", + "frame": {"x":1983,"y":1,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Birch1", + "frame": {"x":1627,"y":663,"w":80,"h":112}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":80,"h":112}, + "sourceSize": {"w":80,"h":112}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Campfire with food sheet", + "frame": {"x":1627,"y":405,"w":160,"h":256}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":160,"h":256}, + "sourceSize": {"w":160,"h":256}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "cloud5", + "frame": {"x":743,"y":349,"w":110,"h":35}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":110,"h":35}, + "sourceSize": {"w":110,"h":35}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Cooking area", + "frame": {"x":803,"y":935,"w":768,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":768,"h":64}, + "sourceSize": {"w":768,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "GandalfHardcore Background layers", + "frame": {"x":1,"y":1,"w":1024,"h":346}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":1024,"h":346}, + "sourceSize": {"w":1024,"h":346}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "GandalfHardcore Portal sheet", + "frame": {"x":1225,"y":789,"w":640,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":640,"h":64}, + "sourceSize": {"w":640,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "HP bar preview", + "frame": {"x":1027,"y":1,"w":696,"h":384}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":696,"h":384}, + "sourceSize": {"w":696,"h":384}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "male_idle", + "frame": {"x":1,"y":874,"w":800,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":800,"h":64}, + "sourceSize": {"w":800,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "male_run", + "frame": {"x":1,"y":940,"w":800,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":800,"h":64}, + "sourceSize": {"w":800,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "male_walk", + "frame": {"x":803,"y":869,"w":800,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":800,"h":64}, + "sourceSize": {"w":800,"h":64}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "sun", + "frame": {"x":1789,"y":405,"w":32,"h":32}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":32,"h":32}, + "sourceSize": {"w":32,"h":32}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "TilesAssets", + "frame": {"x":1225,"y":387,"w":400,"h":400}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":400,"h":400}, + "sourceSize": {"w":400,"h":400}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Tree2", + "frame": {"x":1725,"y":1,"w":256,"h":208}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":256,"h":208}, + "sourceSize": {"w":256,"h":208}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "water2ds", + "frame": {"x":1,"y":349,"w":740,"h":523}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":740,"h":523}, + "sourceSize": {"w":740,"h":523}, + "pivot": {"x":0.5,"y":0.5} +}, +{ + "filename": "Weeping Willow1", + "frame": {"x":1725,"y":211,"w":224,"h":192}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":224,"h":192}, + "sourceSize": {"w":224,"h":192}, + "pivot": {"x":0.5,"y":0.5} +}], +"meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "1.0", + "image": "texture_image_0.webp", + "format": "RGBA8888", + "size": {"w":2048,"h":1024}, + "scale": "1", + "smartupdate": "$TexturePacker:SmartUpdate:100c99bc6065c58490c69f6f368c4ee3:6e253f253e708684eaeaa46c77137f6f:c6b1571f4a98eab5c8284aa57b587db1$" +} +} diff --git a/packages/examples/public/assets/waterOverworld/level/level1.json b/packages/examples/public/assets/waterOverworld/level/level1.json new file mode 100644 index 000000000..789bf4fdb --- /dev/null +++ b/packages/examples/public/assets/waterOverworld/level/level1.json @@ -0,0 +1,1278 @@ +{ + "type": "map", + "height": 20, + "width": 30, + "tilewidth": 32, + "tileheight": 32, + "infinite": false, + "nextobjectid": 1, + "orientation": "orthogonal", + "renderorder": "right-down", + "backgroundcolor": "#ffffff", + "editor": "melon-editor", + "tilesets": [ + { + "firstgid": 1, + "source": "..\\tileset\\foreground.json" + } + ], + "layers": [ + { + "name": "GandalfHardcore Background layers", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 480, + "y": 145, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1.833 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "GandalfHardcore Background layers", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "GandalfHardcore Background layers", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 480, + "y": 173, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "GandalfHardcore Background layers", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "foreground", + "type": "tilelayer", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "tilesetName": "foreground", + "width": 30, + "height": 20, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 2, + 2, + 2, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 37, + 38, + 7, + 35, + 8, + 8, + 39, + 53, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 18, + 16, + 44, + 0, + 0, + 43, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 41, + 16, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 32, + 25, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 3, + 19, + 9, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 12, + 0, + 19, + 9, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 12, + 0, + 0, + 18, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 12, + 0, + 0, + 10, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 25, + 1, + 2, + 27, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 27, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11 + ] + }, + { + "name": "Weeping Willow1", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 68, + "y": 192, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "Weeping Willow1", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "cookingArea", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "cookingArea", + "x": 96, + "y": 256, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": 0 + }, + "properties": { + "image": "Cooking area", + "framewidth": 64, + "frameheight": 64 + }, + "propertytypes": {}, + "duplicateID": 1 + } + ] + }, + { + "name": "Tree2", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 785, + "y": 185, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "Tree2", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "portal", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "portal", + "x": 864, + "y": 256, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": 0 + }, + "properties": { + "image": "GandalfHardcore Portal sheet", + "framewidth": 64, + "frameheight": 64 + }, + "propertytypes": {}, + "duplicateID": 1 + } + ] + }, + { + "name": "foodie", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "foodie", + "x": 416, + "y": 272, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": 0 + }, + "properties": { + "image": "Campfire with food sheet", + "framewidth": 32, + "frameheight": 32 + }, + "propertytypes": {}, + "duplicateID": 1 + } + ] + }, + { + "name": "Angel Statue", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 368, + "y": 128, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "Angel Statue", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "male", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "male", + "x": 165, + "y": 231, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": 0 + }, + "properties": { + "image": "male_idle", + "framewidth": 80, + "frameheight": 64 + }, + "propertytypes": {}, + "duplicateID": 1 + } + ] + }, + { + "name": "Birch1", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": "618", + "y": "232", + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "Birch1", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "sun", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "objects": [ + { + "name": "spriteTP", + "x": 838, + "y": 37, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": null + }, + "properties": { + "anchorPoint": "json:{\"x\":0,\"y\":0}", + "region": "sun", + "texture": "image" + }, + "propertytypes": { + "anchorPoint": "string", + "region": "string", + "texture": "string" + } + } + ] + }, + { + "name": "cloud", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "cloud", + "x": 88, + "y": 29, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": "" + }, + "properties": {}, + "propertytypes": {}, + "duplicateID": 1 + } + ] + }, + { + "name": "cloud", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "cloud", + "x": -141, + "y": 73, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": "" + }, + "properties": {}, + "propertytypes": {}, + "duplicateID": 2 + } + ] + }, + { + "name": "cloud", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "name": "cloud", + "x": 193, + "y": 63, + "rotation": 0, + "visible": true, + "renderLayer": "world", + "inspectors": { + "alpha": 1, + "rotate": 0, + "scale": { + "x": 1, + "y": 1 + }, + "flip": { + "x": false, + "y": false + }, + "z": "" + }, + "properties": {}, + "propertytypes": {}, + "duplicateID": 3 + } + ] + }, + { + "name": "collision", + "type": "objectgroup", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "type": "rect", + "x": -76.19426505493993, + "y": 290.1011299310855, + "width": 1081, + "height": 383.00000000000006, + "rotation": 0, + "visible": true, + "name": "collisionTP", + "properties": [ + { + "name": "collisionType", + "type": "string", + "value": "WORLD_SHAPE" + } + ] + }, + { + "type": "rect", + "x": 289.37148208861385, + "y": 159.94488169736923, + "width": 191.00000000000006, + "height": 64, + "rotation": 0, + "visible": true, + "name": "collisionTP", + "properties": [ + { + "name": "collisionType", + "type": "string", + "value": "WORLD_SHAPE" + } + ] + }, + { + "type": "rect", + "x": 256.82350308614593, + "y": 191.5296841898307, + "width": 255, + "height": 32.00000000000003, + "rotation": 0, + "visible": true, + "name": "collisionTP", + "properties": [ + { + "name": "collisionType", + "type": "string", + "value": "WORLD_SHAPE" + } + ] + }, + { + "type": "rect", + "x": 287.82350308614593, + "y": 223.5296841898307, + "width": 65, + "height": 67.00000000000003, + "rotation": 0, + "visible": true, + "name": "collisionTP", + "properties": [ + { + "name": "collisionType", + "type": "string", + "value": "WORLD_SHAPE" + } + ] + }, + { + "type": "rect", + "x": 447.6811789439849, + "y": 224.52738375915683, + "width": 32, + "height": 18.99999999999997, + "rotation": 0, + "visible": true, + "name": "collisionTP", + "properties": [ + { + "name": "collisionType", + "type": "string", + "value": "WORLD_SHAPE" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/examples/public/assets/waterOverworld/tileset/foreground.json b/packages/examples/public/assets/waterOverworld/tileset/foreground.json new file mode 100644 index 000000000..2595bec8f --- /dev/null +++ b/packages/examples/public/assets/waterOverworld/tileset/foreground.json @@ -0,0 +1 @@ +{"type":"tileset","name":"foreground","columns":9,"tilecount":162,"tilewidth":32,"tileheight":32,"spacing":0,"margin":0,"image":"..\\image\\Floor Tiles1.webp","imagewidth":288,"imageheight":576,"tileproperties":{},"tilepropertytypes":{}} \ No newline at end of file diff --git a/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx b/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx new file mode 100644 index 000000000..cf901d4cd --- /dev/null +++ b/packages/examples/src/examples/waterOverworld/ExampleWaterOverworld.tsx @@ -0,0 +1,38 @@ +/** + * melonJS — Water Overworld example (shader builtins showcase). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import * as me from "melonjs"; +import { useEffect } from "react"; +import { createGame } from "./game"; + +export const ExampleWaterOverworld = () => { + useEffect(() => { + if (!me.game.isInitialized) { + createGame(); + } + }, []); + + return ( + // float above the fixed #screen overlay (see index.css) +
+ A/D or ←/→ move · W/↑ jump · hold Shift to run · S toggles the debug panel + — the pond refracts the scene via screen_texture /{" "} + screen_uv / noise_uv +
+ ); +}; diff --git a/packages/examples/src/examples/waterOverworld/entities.ts b/packages/examples/src/examples/waterOverworld/entities.ts new file mode 100644 index 000000000..b89d72a4d --- /dev/null +++ b/packages/examples/src/examples/waterOverworld/entities.ts @@ -0,0 +1,471 @@ +/** + * melonJS — Water Overworld example (shader builtins showcase). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + * + * Scene and water shader contributed by the melonJS editor team; pixel art + * from GandalfHardcore's free 32x32 side-scroller asset pack (itch.io, + * free to reuse). + */ +import * as me from "melonjs"; + +/* the shared sprite atlas (texture_image_0), created once after preload */ +let gameTexture: me.TextureAtlas | null = null; +export const getGameTexture = () => { + if (gameTexture === null) { + gameTexture = new me.TextureAtlas( + me.loader.getJSON("texture_image_0"), + me.loader.getImage("texture_image_0"), + ); + } + return gameTexture; +}; + +/* one animation strip packed as a single atlas region: slice it into + * frameW x frameH sub-frames (row-major), registering per-frame UVs on the + * atlas — the runtime equivalent of packing each frame separately */ +type StripDef = { + region: string; + frameW: number; + frameH: number; + count: number; + anim: string; + delay: number; +}; + +const buildStripSettings = (texture: me.TextureAtlas, strips: StripDef[]) => { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument -- atlas region internals are untyped */ + const atlas: unknown[] = []; + const atlasIndices: Record = {}; + const animations: Record = {}; + let framewidth = 0; + let frameheight = 0; + const source = (texture as any).getTexture(); + + for (const strip of strips) { + const base = (texture as any).getRegion(strip.region); + if (!base) { + throw new Error(`region "${strip.region}" not found in atlas`); + } + const cols = Math.max(1, Math.floor(base.width / strip.frameW)); + const frames: { name: string; delay: number }[] = []; + for (let i = 0; i < strip.count; i++) { + const key = `${strip.region}#${i}`; + const fx = (i % cols) * strip.frameW; + const fy = Math.floor(i / cols) * strip.frameH; + const entry: Record = {}; + entry[key] = Object.assign({}, base, { + name: key, + offset: new me.Vector2d(base.offset.x + fx, base.offset.y + fy), + width: strip.frameW, + height: strip.frameH, + trimmed: false, + trim: undefined, + sourceSize: { w: strip.frameW, h: strip.frameH }, + angle: 0, + }); + (texture as any).addUVs(entry, key, source.width, source.height); + atlasIndices[key] = atlas.length; + atlas.push(entry[key]); + frames.push({ name: key, delay: strip.delay }); + } + animations[strip.anim] = frames; + framewidth = Math.max(framewidth, strip.frameW); + frameheight = Math.max(frameheight, strip.frameH); + } + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument */ + return { atlas, atlasIndices, framewidth, frameheight, animations }; +}; + +/* register the animations built by buildStripSettings on a sprite */ +const addStripAnimations = ( + sprite: me.Sprite, + animations: Record, + initial: string, +) => { + for (const name of Object.keys(animations)) { + sprite.addAnimation(name, animations[name]); + } + sprite.setCurrentAnimation(initial); +}; + +/** + * Static decor placed by the TMX object layers (trees, statue, sun, …): + * a centered sprite showing one atlas region named by the object's + * `region` property. + */ +export class SpriteTP extends me.Sprite { + constructor(x: number, y: number, settings: any = {}) { + settings.image = getGameTexture(); + // the editor stores anchorPoint as a "json:..." string — decor is + // always centered on its placement point, same as the original + settings.anchorPoint = { x: 0.5, y: 0.5 }; + super(x, y, settings); + this.floating = false; + } +} + +/** + * Static world collision from the TMX `collision` layer (the TMX parser + * pre-builds `settings.shapes` from the rect objects). + */ +export class CollisionTP extends me.Renderable { + constructor(x: number, y: number, settings: any = {}) { + super(x, y, settings.width, settings.height); + this.anchorPoint.set(0, 0); + this.body = new me.Body(this, settings.shapes); + this.body.collisionType = me.collision.types.WORLD_SHAPE; + this.body.setStatic(true); + } +} + +/** a drifting-sky cloud — a single centered atlas frame */ +export class Cloud extends me.Sprite { + private speed = 1 + Math.random(); + private startX = -141; + + constructor(x: number, y: number, settings: any = {}) { + settings.image = getGameTexture(); + settings.region = "cloud5"; + settings.anchorPoint = { x: 0.5, y: 0.5 }; + super(x, y, settings); + this.alwaysUpdate = true; + } + + override update(dt: number) { + super.update(dt); + // drift right, respawn off the left edge (original demo behavior) + this.pos.x += this.speed; + if (this.pos.x >= 1040) { + this.pos.x = this.startX - (50 + Math.random() * 200); + } + return true; + } +} + +/* animated one-strip props (campfire food, portal, cooking area) */ +const makeStripProp = (strip: StripDef) => { + return class StripProp extends me.Sprite { + constructor(x: number, y: number, settings: any = {}) { + const texture = getGameTexture(); + const built = buildStripSettings(texture, [strip]); + settings.image = texture; + settings.atlas = built.atlas; + settings.atlasIndices = built.atlasIndices; + settings.framewidth = built.framewidth; + settings.frameheight = built.frameheight; + settings.anchorPoint = { x: 0.5, y: 0.5 }; + super(x, y, settings); + addStripAnimations(this, built.animations, strip.anim); + } + }; +}; + +export const Foodie = makeStripProp({ + region: "Campfire with food sheet", + frameW: 32, + frameH: 32, + count: 40, + anim: "camp", + delay: 100, +}); + +export const Portal = makeStripProp({ + region: "GandalfHardcore Portal sheet", + frameW: 64, + frameH: 64, + count: 10, + anim: "portal", + delay: 100, +}); + +export const CookingArea = makeStripProp({ + region: "Cooking area", + frameW: 64, + frameH: 64, + count: 12, + anim: "cook", + delay: 100, +}); + +/** + * The playable character: A/D walk, W jump, hold Shift to run. + * (Plain melonJS input/body code with the same tuning as the original + * demo's controller: maxVel (2, 15) — 5 while running — friction 0.5.) + */ +export class Male extends me.Sprite { + private running = false; + + constructor(x: number, y: number, settings: any = {}) { + const texture = getGameTexture(); + const built = buildStripSettings(texture, [ + { + region: "male_idle", + frameW: 80, + frameH: 64, + count: 5, + anim: "idle", + delay: 180, + }, + { + region: "male_walk", + frameW: 80, + frameH: 64, + count: 8, + anim: "walk", + delay: 80, + }, + { + region: "male_run", + frameW: 80, + frameH: 64, + count: 8, + anim: "run", + delay: 80, + }, + ]); + settings.image = texture; + settings.atlas = built.atlas; + settings.atlasIndices = built.atlasIndices; + settings.framewidth = built.framewidth; + settings.frameheight = built.frameheight; + settings.anchorPoint = { x: 0.5, y: 0.5 }; + super(x, y, settings); + addStripAnimations(this, built.animations, "idle"); + + // hit box authored in frame-local (top-left) space; the sprite is + // center-anchored, so shift it by half the frame like the original + this.body = new me.Body( + this, + new me.Rect(35 - this.width * 0.5, 21 - this.height * 0.5, 9, 43), + ); + this.body.collisionType = me.collision.types.PLAYER_OBJECT; + this.body.setMaxVelocity(2, 15); + this.body.setFriction(0.5, 0); + + me.input.bindKey(me.input.KEY.A, "left"); + me.input.bindKey(me.input.KEY.LEFT, "left"); + me.input.bindKey(me.input.KEY.D, "right"); + me.input.bindKey(me.input.KEY.RIGHT, "right"); + me.input.bindKey(me.input.KEY.W, "jump", true); + me.input.bindKey(me.input.KEY.UP, "jump", true); + me.input.bindKey(me.input.KEY.SHIFT, "runLock"); + + this.alwaysUpdate = true; + } + + override update(dt: number) { + const body = this.body; + + this.running = me.input.isKeyPressed("runLock"); + body.maxVel.x = this.running ? 5 : 2; + + if (me.input.isKeyPressed("left")) { + body.force.x = -body.maxVel.x; + } else if (me.input.isKeyPressed("right")) { + body.force.x = body.maxVel.x; + } else { + body.force.x = 0; + } + + if (me.input.isKeyPressed("jump")) { + if (!body.jumping && !body.falling) { + body.vel.y = -body.maxVel.y; + body.jumping = true; + } + } + + // idle / walk / run + facing, mirroring the original demo + if (body.vel.x === 0 && body.vel.y === 0) { + if (!this.isCurrentAnimation("idle")) { + this.setCurrentAnimation("idle"); + } + } else { + const anim = this.running ? "run" : "walk"; + if (!this.isCurrentAnimation(anim)) { + this.setCurrentAnimation(anim); + } + } + if (body.vel.x < 0) { + this.flipX(false); + } else if (body.vel.x > 0) { + this.flipX(true); + } + + return super.update(dt) || body.vel.x !== 0 || body.vel.y !== 0; + } + + override onDeactivateEvent() { + me.input.unbindKey(me.input.KEY.A); + me.input.unbindKey(me.input.KEY.LEFT); + me.input.unbindKey(me.input.KEY.D); + me.input.unbindKey(me.input.KEY.RIGHT); + me.input.unbindKey(me.input.KEY.W); + me.input.unbindKey(me.input.KEY.UP); + me.input.unbindKey(me.input.KEY.SHIFT); + } +} + +/** + * The pond — an animated water sprite whose post effect refracts + * EVERYTHING drawn behind it, using the shader builtins: + * + * - `screenTex : screen_texture` — the engine keeps this sampler filled + * with a capture of the screen so far (no JS plumbing) + * - `screen_uv` — this fragment's position in that capture + * - `noise_uv` — 0..1 across the water sprite itself, so the seamless + * noise textures tile independently of where the frame sits in the atlas + * + * Shader and tuning by the melonJS editor team (ported verbatim). + */ +export class WaterTextureObj extends me.Sprite { + private water: me.ShaderEffect; + + constructor(x: number, y: number, settings: any = {}) { + const texture = getGameTexture(); + // the two water frames, used verbatim (no trim/re-align — the 2dWater + // art carries its own transparent sky band above the water surface) + const built = buildStripSettings(texture, [ + { + region: "2dWater", + frameW: 480, + frameH: 480, + count: 1, + anim: "water", + delay: 100, + }, + { + region: "water2ds", + frameW: 740, + frameH: 523, + count: 1, + anim: "water2", + delay: 100, + }, + ]); + settings.image = texture; + settings.atlas = built.atlas; + settings.atlasIndices = built.atlasIndices; + settings.framewidth = built.framewidth; + settings.frameheight = built.frameheight; + settings.anchorPoint = { x: 0.5, y: 0.5 }; + super(x, y, settings); + addStripAnimations(this, built.animations, "water"); + + const scale = settings.inspectors?.scale ?? { x: 1, y: 1 }; + this.scale(scale.x, scale.y); + + const noise = new me.NoiseTexture2d({ + type: "cellular", + fractalType: "fbm", + width: 512, + height: 512, + frequency: 0.05, + octaves: 4, + gain: 0.5, + pingPongStrength: 2, + lacunarity: 2, + cellularJitter: 1, + seamlessBlendSkirt: 0.1, + domainWarpAmp: 30, + domainWarpFrequency: 0.01, + speed: 0.5, + seamless: true, + seed: 0, + }); + const noise2 = new me.NoiseTexture2d({ + type: "cellular", + fractalType: "fbm", + width: 512, + height: 512, + frequency: 0.005, + octaves: 4, + gain: 0.5, + pingPongStrength: 2, + lacunarity: 2, + cellularJitter: 1, + seamlessBlendSkirt: 0.1, + domainWarpAmp: 30, + domainWarpFrequency: 0.01, + speed: 0.5, + seamless: true, + seed: 0, + }); + + const fragment = ` + uniform sampler2D uNoise; + uniform sampler2D uNoise2; + uniform sampler2D screenTex : screen_texture; + uniform float rangeWater; + uniform float uTime; + + uniform float uEdgeAmp; + uniform float uEdgeFreq; + uniform float uScreenAmp; + uniform float uScreenFreq; + uniform float uFlowAmp; + uniform float uNoiseAmp; + + vec4 apply(vec4 color, vec2 uv) { + float t = mod(uTime, 6283.18); + + vec2 flow1 = texture2D(uNoise, noise_uv * 1.0 + t * 0.05).rg; + vec2 flow2 = texture2D(uNoise, noise_uv * 2.3 - t * 0.03).rg; + vec2 flow = (flow1 + flow2 * 0.5) - 0.75; + vec2 noise = 2.0 * texture2D(uNoise2, noise_uv + vec2(0.5, 0.2) * t).rg - vec2(1.0); + + float n = texture2D(uNoise, vec2(noise_uv.x * 3.0, t * 0.1)).r; + float edgeWave = sin(noise_uv.x * uEdgeFreq + t * 1.8) * uEdgeAmp + + sin(noise_uv.x * uEdgeFreq * 2.2 - t * 2.6) * uEdgeAmp * 0.5 + + (n - 0.5) * uEdgeAmp * 0.6; + vec2 edgeOffset = vec2(0.0, edgeWave); + + float screenEdgeWave = sin(screen_uv.x * uScreenFreq + t * 1.4) * uScreenAmp + + sin(screen_uv.x * uScreenFreq * 2.2 - t * 2.1) * uScreenAmp * 0.5; + float dynamicRange = rangeWater + screenEdgeWave; + + vec2 normalizedUV = screen_uv; + normalizedUV.y = dynamicRange - normalizedUV.y; + + vec4 refractedScreen = texture2D(screenTex, normalizedUV + flow * uFlowAmp); + refractedScreen *= texture2D(uSampler, uv + edgeOffset + noise * uNoiseAmp); + return refractedScreen; + } + `; + + const water = new me.ShaderEffect(me.video.renderer, fragment); + water.setTexture("uNoise", noise.getTexture(), "repeat"); + water.setTexture("uNoise2", noise2.getTexture(), "repeat"); + + // default amplitudes (original demo values) + water.setUniform("uEdgeAmp", 0.002); + water.setUniform("uEdgeFreq", 60.0); + water.setUniform("uScreenAmp", 0.0008); + water.setUniform("uScreenFreq", 45.0); + water.setUniform("uFlowAmp", 0.03); + water.setUniform("uNoiseAmp", 0.0015); + water.setUniform("rangeWater", 1.02); + + this.water = water; + this.addPostEffect(water); + this.alwaysUpdate = true; + } + + override update(dt: number) { + this.water.setTime(me.timer.getTime() / 1000); + super.update(dt); + return true; + } +} + +/** register every TMX-spawnable entity in the object pool */ +export const registerEntities = () => { + me.pool.register("spriteTP", SpriteTP); + me.pool.register("collisionTP", CollisionTP); + me.pool.register("cloud", Cloud); + me.pool.register("foodie", Foodie); + me.pool.register("portal", Portal); + me.pool.register("cookingArea", CookingArea); + me.pool.register("male", Male); + me.pool.register("waterTextureObj", WaterTextureObj); +}; diff --git a/packages/examples/src/examples/waterOverworld/game.ts b/packages/examples/src/examples/waterOverworld/game.ts new file mode 100644 index 000000000..c2202271b --- /dev/null +++ b/packages/examples/src/examples/waterOverworld/game.ts @@ -0,0 +1,62 @@ +/** + * melonJS — Water Overworld example (shader builtins showcase). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { loader, plugin, state, video } from "melonjs"; +import { registerEntities } from "./entities"; +import { WaterOverworldStage } from "./play"; + +const base = `${import.meta.env.BASE_URL}assets/waterOverworld/`; +const resources = [ + { name: "level1", type: "tmx", src: `${base}level/level1.json` }, + { name: "foreground", type: "tsx", src: `${base}tileset/foreground.json` }, + { + name: "Floor Tiles1", + type: "image", + src: `${base}image/Floor Tiles1.webp`, + }, + { + name: "texture_image_0", + type: "image", + src: `${base}image/texture_image_0.webp`, + }, + { + name: "texture_image_0", + type: "json", + src: `${base}json/texture_image_0.json`, + }, +]; + +export const createGame = () => { + // the water shader needs WebGL (ShaderEffect is inert under Canvas) + try { + if ( + !video.init(960, 640, { + parent: "screen", + renderer: video.WEBGL, + scale: "auto", + scaleMethod: "fit", + antiAlias: false, + subPixel: false, + }) + ) { + alert("This example requires WebGL"); + return; + } + } catch { + alert("This example requires WebGL"); + return; + } + + // register the debug plugin (press "S" to toggle the panel) + plugin.register(DebugPanelPlugin, "debugPanel"); + + registerEntities(); + + loader.preload(resources, () => { + state.set(state.PLAY, new WaterOverworldStage()); + state.change(state.PLAY, false); + }); +}; diff --git a/packages/examples/src/examples/waterOverworld/play.ts b/packages/examples/src/examples/waterOverworld/play.ts new file mode 100644 index 000000000..b6aa61ddb --- /dev/null +++ b/packages/examples/src/examples/waterOverworld/play.ts @@ -0,0 +1,27 @@ +/** + * melonJS — Water Overworld example (shader builtins showcase). + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import * as me from "melonjs"; + +export class WaterOverworldStage extends me.Stage { + override onResetEvent() { + // the TMX places everything except the water: decor sprite layers, + // the animated props, the player and the world collision + me.level.load("level1"); + + me.game.viewport.fadeOut("#000000", 2000); + + // the refracting pond, over the scene (z 20), scaled like the + // original demo + me.game.world.addChild( + me.pool.pull("waterTextureObj", 480, 301, { + inspectors: { + scale: { x: 2.032, y: 2.032 }, + }, + }) as me.Renderable, + 20, + ); + } +} diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 799416d49..abfaa840a 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -226,6 +226,11 @@ const ExampleVideo = lazy(() => default: m.ExampleVideo, })), ); +const ExampleWaterOverworld = lazy(() => + import("./examples/waterOverworld/ExampleWaterOverworld").then((m) => ({ + default: m.ExampleWaterOverworld, + })), +); const ExampleWhacAMole = lazy(() => import("./examples/whac-a-mole/ExampleWhacAMole").then((m) => ({ default: m.ExampleWhacAMole, @@ -574,6 +579,14 @@ const examples: { description: "Video playback using the asset loader and Sprite class to display an MP4 video on canvas.", }, + { + component: , + label: "Water Overworld", + path: "waterOverworld", + sourceDir: "waterOverworld", + description: + "Side-scroller overworld with a refracting pond — the Godot-style screen_texture / screen_uv / noise_uv shader builtins.", + }, { component: , label: "Whac-A-Mole", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 4189aab10..4033d46ea 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,7 @@ ## [19.9.0] (melonJS 2) - _unreleased_ ### Added -- **`renderer.toFrameTexture()` — GPU frame capture as a `Texture2d`** (closes [#1544](https://github.com/melonjs/melonJS/issues/1544)) — capture the current camera view / framebuffer into a `Texture2d` entirely on the GPU, for screen-space effects (water refraction, heat haze, frosted glass) that need to sample "what's on screen behind me". The fourth member of the `toDataURL` / `toBlob` / `toImageBitmap` family — "the current frame as X" — and the only one whose result never leaves the GPU: a `copyTexImage2D` of the bound framebuffer, ~10–100× cheaper than a `readPixels` round-trip and with zero CPU⇄GPU stall. The capture reflects the framebuffer **at the call site** (a `flush()` runs first — Godot `BackBufferCopy` placement), reads whichever framebuffer is bound (backbuffer or a camera post-effect FBO, so it works under `Camera2d` and `Camera3d`), and returns a `Texture2d` you feed straight to a custom effect: `effect.setTexture("uScene", renderer.toFrameTexture())`. `setTexture` now binds such a GPU-resident capture as a **live** handle — re-capturing each frame into the shared slot refreshes what the shader samples with no re-bind. Options: `target` (reuse/mint a caller-owned capture) and `region` (sub-region copy). Color only (RGB, opaque); the Canvas renderer ships an offscreen-canvas equivalent for family parity. See the new **Aquarium** example (fish rendered to the scene, refracted through a rippling water surface that samples the captured frame). +- **`renderer.toFrameTexture()` — GPU frame capture as a `Texture2d`** (closes [#1544](https://github.com/melonjs/melonJS/issues/1544)) — capture the current camera view / framebuffer into a `Texture2d` entirely on the GPU, for screen-space effects (water refraction, heat haze, frosted glass) that need to sample "what's on screen behind me". The fourth member of the `toDataURL` / `toBlob` / `toImageBitmap` family — "the current frame as X" — and the only one whose result never leaves the GPU: a `copyTexImage2D` of the bound framebuffer, ~10–100× cheaper than a `readPixels` round-trip and with zero CPU⇄GPU stall. The capture reflects the framebuffer **at the call site** (a `flush()` runs first — the classic back-buffer-copy placement), reads whichever framebuffer is bound (backbuffer or a camera post-effect FBO, so it works under `Camera2d` and `Camera3d`), and returns a `Texture2d` you feed straight to a custom effect: `effect.setTexture("uScene", renderer.toFrameTexture())`. `setTexture` now binds such a GPU-resident capture as a **live** handle — re-capturing each frame into the shared slot refreshes what the shader samples with no re-bind. Options: `target` (reuse/mint a caller-owned capture) and `region` (sub-region copy). Color only (RGB, opaque); the Canvas renderer ships an offscreen-canvas equivalent for family parity. See the new **Aquarium** example (fish rendered to the scene, refracted through a rippling water surface that samples the captured frame). - **BMFont XML font support for `BitmapText`** — bitmap fonts can now be loaded from the AngelCode BMFont **XML** flavour in addition to the text `.fnt` format (the serialisation is auto-detected). Many bitmap-font tools and asset packs (itch.io, dafont, …) export PNG + XML; those now load directly through the standard preloader (`{ type: "binary" }`), with no offline conversion step. The XML is parsed with a dependency-free regex parser (no `DOMParser`), so it also works outside the browser (Node / SSR) — unlike the DOM-based TMX XML path. - **Holes & compound paths in `Path2D` / SVG fills** (#1253) — a path may now contain multiple sub-paths (every `M` in an SVG string, or every `moveTo()` call, starts a new one). On `fill()` the first sub-path is the outer contour and each subsequent sub-path is treated as a hole, so donuts, rings, letter counters ("O", "A", "8") and other shapes with holes render correctly under both the WebGL renderer (earcut hole triangulation) and the Canvas renderer (native winding rule). Method semantics follow the standard [`Path2D`](https://developer.mozilla.org/en-US/docs/Web/API/Path2D) API; see the updated **SVG Shapes** example. - **`Texture2d` base class for texture assets** — a new abstract base (`me.Texture2d`) for user-constructed texture objects that own a texture source, exposed via `getTexture()`. `TextureAtlas` now extends it (no behavior change), and the renderables (`Sprite`, `Sprite3d`, `Mesh`) recognize any `Texture2d` by type and resolve it to its backing source — so a texture asset can be passed directly (`{ image: myTexture }`) just like a raw canvas. Raw DOM images/canvases and the loader's decoded `CompressedImage` data remain valid sources outside the class hierarchy. The contract deliberately admits both CPU-backed sources (a drawable canvas/image) and **GPU-resident** ones (an opaque backing that never leaves the GPU) — the shape a future WebGPU backend and screen-capture textures follow. @@ -12,7 +12,8 @@ - **`ShaderEffect.setTime(seconds)`** — a convenience for the `uTime` shader-animation convention, now on the base class: call it once per frame to write a shader's `uniform float uTime` (e.g. to scroll a static texture's UVs, or pulse / wave), driven by whatever clock you choose. A no-op when the shader doesn't declare `uTime`, so it's safe on any effect. Manual by design — the engine never calls it, so animation stays opt-in. The built-in `Hologram` / `Shine` effects now inherit it (their identical `setTime` overrides were removed). - **`ShaderEffect.setTexture(name, image[, repeat])`** (closes [#1532](https://github.com/melonjs/melonJS/issues/1532)) — bind an **extra** `sampler2D` to a custom post-effect, beyond the sprite/target it processes (`uSampler`) — a noise map, mask, gradient, or flow table. Declare `uniform sampler2D ;` in your fragment and pass any engine texture — a `Texture2d` asset (`NoiseTexture2d`, `TextureAtlas`, …) directly, or a raw drawable source; the engine uploads it once, caches it, and (re)binds it to a reserved texture unit each draw while pointing the sampler uniform at it — no raw WebGL texture-unit juggling. Works on both post-effect paths (single-effect sprite shader and the multi-effect / camera FBO blit). Enables UV-distortion water, dissolve masks, flow maps, palette lookups, etc. Purely additive — effects that never call it are unchanged. See the **Aquarium** example (a water surface refracting the live scene through a GPU-scrolled seamless `NoiseTexture2d`). - **Anchor-point presets + `Sprite3d` anchor support** (#1514) — `settings.anchorPoint` now accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` in addition to an `{x, y}` object, uniformly across `Sprite`, `Entity`, `Collectable`, `ImageLayer`, `Text`, `BitmapText` (and their subclasses), including as a plain-string Tiled property. `Sprite3d` gains anchoring through the same `settings.anchorPoint` key (centered default, same convention as `Sprite`): the anchor is baked into the quad's local vertices so it composes with billboarding, flips and trimmed/rotated atlas frames, never leaks into the renderer transform on either camera path, is mutable at runtime via `sprite3d.anchorPoint.set(...)` (automatic re-bake + cull-bounds update), and grows the frustum-cull bounds exactly so an anchored sprite can't pop out while still on screen. On the 2D classes invalid values (unknown preset, malformed object) keep their historical silent `(0, 0)` outcome for full backward compatibility, but now log a console warning; `Sprite3d` (a new surface) throws. See the updated billboard example (`anchorPoint: "bottom"` — feet on the ground plane). -- **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Aquarium** and **Heat Haze** examples ship their fragments as preloaded shader assets. +- **Shader builtins: `screen_texture` / `screen_uv` / `noise_uv`** — three names now get special treatment inside a `ShaderEffect` fragment body, so screen-reading and noise-scrolling effects need zero JavaScript plumbing and no UV math (design contributed by the melonJS editor team). Annotate a sampler with `uniform sampler2D screenTex : screen_texture;` and the engine keeps it filled with a capture of **everything drawn so far** (a true back-buffer copy via the `toFrameTexture` shared slot, refreshed automatically when the effect draws — one screen copy per draw, only for effects that use it); an optional wrap is accepted (`: screen_texture(repeat)`, WebGL 2). Use `screen_uv` anywhere in the body for the fragment's 0..1 screen position, and `noise_uv` for a frame-local 0..1 coordinate across the drawn object — it undoes atlas packing (and is scaled to object pixels), so a seamless noise texture tiles correctly no matter where the sprite's frame sits in its atlas. Fully backward compatible: the annotation was never valid GLSL, the varyings only activate when referenced, shaders that declare their own `screen_uv`/`noise_uv` are left untouched, and a body using none of them compiles byte-identical to before. See the new **Water Overworld** example — a side-scroller pond that refracts the scene behind it in three lines of shader. +- **Shader preloading: `"shader"` asset type + `loader.getShader()` + `ShaderEffect.clone()`** — custom shaders can now be preloaded like any other asset and are **compiled at load time** (the GLSL compile cost lands in the loading screen, and a compile error fails the load — `loader.load()` rejects with an error carrying the asset's name). The source is a GLSL fragment body following the `ShaderEffect` convention (`uniform` declarations + `vec4 apply(vec4 color, vec2 uv)`), loaded from a `src` URL / data: URI or inline via the `data` field (the inline-TMX convention): `{ name: "waterRipple", type: "shader", src: "shaders/waterRipple.frag" }`. `loader.getShader(name)` returns the **shared, loader-owned instance** — the *same* object on every call, with `shared = true` so renderable cleanup never auto-destroys it; everything it is assigned to shares one set of uniform values, and it is freed only by `loader.unload()` / `loader.unloadAll()` (which destroy the GL program). For per-renderable uniform values, the new `ShaderEffect.clone()` compiles an independent, caller-owned copy: it copies the *recipe* (fragment source, precision, uniform values, `setTexture` bindings — with its own GL uploads) but never the *ownership* — the clone's `shared` flag is **always reset to `false`**, so it is auto-destroyed with the renderable it is assigned to unless explicitly re-shared. `GLShader.clone()` exists likewise for raw full-program (vertex + fragment) shaders, with the same recipe-not-ownership semantics. The **Aquarium** and **Heat Haze** examples ship their fragments as preloaded shader assets. A shader asset can also be a complete **`{vertex, fragment}` program pair** — `src: {vertex: url, fragment: url}` or inline via `data:` — which compiles into a shared raw **`GLShader`** instead (returned by the same `loader.getShader()`), for the paths that take one directly: a `Mesh` custom shader, `renderer.customShader`, a custom batcher. Same shared/clone/unload lifecycle; WebGL-only (`null` under Canvas — a raw program has no canvas analog). ### Fixed - **Asset loading failed over the `file:` protocol (Cordova/Capacitor APK on Android)** — `fetchData` uses `fetch()`, which several WebView contexts refuse for `file://` URLs (it rejects with a network error). The engine previously relied on `fetch` returning `status 0` for `file:` (added when the old `XMLHttpRequest` loader was removed), but that only helps where `fetch` *resolves* — a Cordova Android WebView rejects outright, so preloading broke. `fetchData` now falls back to `XMLHttpRequest` for `file:` URLs (detected via the URL scheme or `location.protocol`), which supports the scheme (a successful local read reports `status 0`); `http(s)` URLs are unchanged. Restores the pre-fetch behaviour for packaged builds. diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index 8be750e15..22a2725d6 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -728,9 +728,11 @@ export function unload(asset) { if (typeof effect === "undefined") { return false; } - // the loader owns the shared ShaderEffect — actually free the GL - // program, don't just drop the cache entry - effect.destroy(); + // the loader owns the shared ShaderEffect/GLShader — actually free + // the GL program, don't just drop the cache entry. A `null` entry + // is a {vertex, fragment} pair loaded under the Canvas renderer + // (no GL program to free). + effect?.destroy(); delete shaderList[asset.name]; return true; } @@ -1030,14 +1032,21 @@ export function getGLTF(elt) { * is reset to `false`, so it is auto-destroyed with the renderable it is * assigned to, like any hand-constructed effect. * - * Shader assets are WebGL-only: under the Canvas renderer the returned - * effect is an inert stub (same behavior as constructing a `ShaderEffect` - * directly). Note that shader assets require `video.init()` to have been - * called — an inherent precondition of the preload flow, since the loading - * screen itself needs the renderer. + * A shader asset declared as a **`{vertex, fragment}` program pair** (see + * the example) compiles into a raw {@link GLShader} instead — the type the + * advanced paths take directly (a `Mesh` custom shader, + * `renderer.customShader`, a custom batcher). Same shared-instance + * semantics, and `GLShader.clone()` likewise yields a caller-owned copy. + * + * Shader assets are WebGL-only: under the Canvas renderer a fragment-body + * asset returns an inert `ShaderEffect` stub (same behavior as constructing + * one directly), and a program-pair asset returns `null` (a raw GL program + * has no canvas analog). Note that shader assets require `video.init()` to + * have been called — an inherent precondition of the preload flow, since + * the loading screen itself needs the renderer. * @memberof loader * @param {string} elt - name of the shader asset (as specified in the preload list) - * @returns {ShaderEffect|null} the shared, precompiled effect, or `null` if not found + * @returns {ShaderEffect|GLShader|null} the shared, precompiled shader, or `null` if not found * @category Assets * @example * me.loader.preload([ @@ -1048,11 +1057,18 @@ export function getGLTF(elt) { * uniform float uIntensity; * vec4 apply(vec4 color, vec2 uv) { return mix(color, vec4(1.0), uIntensity); } * ` }, + * // or a complete {vertex, fragment} program pair → a raw GLShader + * { name: "toonMesh", type: "shader", src: { + * vertex: "shaders/toon.vert", + * fragment: "shaders/toon.frag", + * } }, * ], () => { * // one shared program — same uniform state for every user * mySprite.shader = me.loader.getShader("waterRipple"); * // private copy with its own uniforms (caller-owned, shared = false) * boss.shader = me.loader.getShader("flash").clone(); + * // a program pair comes back as a GLShader, e.g. for a custom Mesh shader + * renderer.drawMesh(myMesh, { shader: me.loader.getShader("toonMesh") }); * }); */ export function getShader(elt) { diff --git a/packages/melonjs/src/loader/parsers/shader.js b/packages/melonjs/src/loader/parsers/shader.js index 7d1574053..7f03d28fd 100644 --- a/packages/melonjs/src/loader/parsers/shader.js +++ b/packages/melonjs/src/loader/parsers/shader.js @@ -1,4 +1,5 @@ import { on, VIDEO_INIT } from "../../system/event.ts"; +import GLShader from "../../video/webgl/glshader.js"; import ShaderEffect from "../../video/webgl/shadereffect.js"; import { shaderList } from "../cache.js"; import { fetchData } from "./fetchdata.js"; @@ -13,9 +14,16 @@ on(VIDEO_INIT, (renderer) => { }); /** - * compile a shader asset's source into its shared, loader-owned ShaderEffect. - * @param {string} source - the fragment body (uniforms + `apply(color, uv)`) - * @returns {ShaderEffect} the compiled effect, flagged `shared` + * compile a shader asset's source into its shared, loader-owned instance: + * a fragment body (the `apply()` convention) compiles into a + * {@link ShaderEffect}; a full `{vertex, fragment}` program pair compiles + * into a raw {@link GLShader} — for the advanced paths that take one (a + * `Mesh` custom shader, `renderer.customShader`, a custom batcher). + * Program pairs are WebGL-only: under the Canvas renderer a pair asset + * stores `null` (with a warning) — a raw GL program has no canvas analog, + * unlike ShaderEffect's inert Canvas stub. + * @param {string|{vertex: string, fragment: string, precision?: string}} source - the fragment body, or a complete program pair + * @returns {ShaderEffect|GLShader|null} the compiled asset, flagged `shared` * @throws if called before `video.init()` (no renderer to compile against) * @ignore */ @@ -25,6 +33,30 @@ export function compileShaderAsset(source) { "shader assets require video.init() to be called first (no renderer available to compile against)", ); } + if (typeof source === "object" && source !== null) { + if ( + typeof source.vertex !== "string" || + typeof source.fragment !== "string" + ) { + throw new Error( + "a program pair needs both `vertex` and `fragment` GLSL sources", + ); + } + if (typeof _renderer.gl === "undefined") { + console.warn( + "shader asset: {vertex, fragment} program pairs require WebGL and are unavailable in Canvas mode", + ); + return null; + } + const shader = new GLShader( + _renderer.gl, + source.vertex, + source.fragment, + source.precision, + ); + shader.shared = true; + return shader; + } const effect = new ShaderEffect(_renderer, source); // loader-owned: a renderable's cleanup must never auto-destroy it — // only loader.unload() / unloadAll() does (see the `shared` flag) @@ -33,14 +65,22 @@ export function compileShaderAsset(source) { } /** - * parse/preload a shader asset (a GLSL fragment body following the - * ShaderEffect convention: uniform declarations + `vec4 apply(vec4, vec2)`), - * from a `src` URL (or data: URI) or inline GLSL via the `data` field. - * Always compiled into a shared ShaderEffect AT LOAD TIME, so the GLSL - * compile cost lands in the loading screen and compile errors carry the - * asset name. `video.init()` is an inherent precondition of the preload - * flow (the loading screen itself needs the renderer, and a failed init - * throws and halts) — loading a shader without it fails with a clear error. + * parse/preload a shader asset, from a `src` URL (or data: URI) or inline + * GLSL via the `data` field. Two source shapes are accepted: + * + * - a GLSL **fragment body** following the ShaderEffect convention (uniform + * declarations + `vec4 apply(vec4, vec2)`) → compiles into a shared + * {@link ShaderEffect}; + * - a complete **program pair** — `src: {vertex: url, fragment: url}` or + * `data: {vertex: glsl, fragment: glsl}` — → compiles into a shared raw + * {@link GLShader}, for the advanced paths that take one (a `Mesh` + * custom shader, `renderer.customShader`, a custom batcher). + * + * Always compiled AT LOAD TIME, so the GLSL compile cost lands in the + * loading screen and compile errors carry the asset name. `video.init()` + * is an inherent precondition of the preload flow (the loading screen + * itself needs the renderer, and a failed init throws and halts) — + * loading a shader without it fails with a clear error. * @param {loader.Asset} data - asset data * @param {Function} [onload] - function to be called when the resource is loaded * @param {Function} [onerror] - function to be called in case of error @@ -54,8 +94,12 @@ export function preloadShader(data, onload, onerror, settings) { return 0; } - // inline GLSL source via the `data` field (same convention as inline TMX) - if (typeof data.data === "string") { + // inline GLSL source via the `data` field (same convention as inline + // TMX): a fragment-body string, or a {vertex, fragment} program pair + if ( + typeof data.data === "string" || + (typeof data.data === "object" && data.data !== null) + ) { try { shaderList[data.name] = compileShaderAsset(data.data); } catch (error) { @@ -70,11 +114,52 @@ export function preloadShader(data, onload, onerror, settings) { return 1; } + // `src` as a {vertex, fragment} pair of URLs → fetch both, compile a + // raw GLShader program + if (typeof data.src === "object" && data.src !== null) { + if ( + typeof data.src.vertex !== "string" || + typeof data.src.fragment !== "string" + ) { + if (typeof onerror === "function") { + onerror( + new Error( + `shader asset "${data.name}": a program pair needs both \`src.vertex\` and \`src.fragment\` URLs`, + ), + ); + } + return 1; + } + Promise.all([ + fetchData(data.src.vertex, "text", settings), + fetchData(data.src.fragment, "text", settings), + ]) + .then(([vertex, fragment]) => { + // concurrent-load guard — see the single-source path below + if (typeof shaderList[data.name] === "undefined") { + shaderList[data.name] = compileShaderAsset({ + vertex, + fragment, + precision: data.precision, + }); + } + if (typeof onload === "function") { + onload(); + } + }) + .catch((error) => { + if (typeof onerror === "function") { + onerror(new Error(`shader asset "${data.name}": ${error.message}`)); + } + }); + return 1; + } + if (typeof data.src !== "string") { if (typeof onerror === "function") { onerror( new Error( - `shader asset "${data.name}": needs a \`src\` URL or inline GLSL via \`data\``, + `shader asset "${data.name}": needs a \`src\` URL (or {vertex, fragment} URL pair), or inline GLSL via \`data\``, ), ); } diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 9440ca3ef..55c36168b 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -429,6 +429,18 @@ export default class LitQuadBatcher extends QuadBatcher { this.currentShader.setUniform("uSampler", unit); this.currentSamplerUnit = unit; } + // feed the effect's `noise_uv` builtin — see QuadBatcher.addQuad + if (typeof this.currentShader._setNoiseUVRect === "function") { + const source = texture.getTexture(); + this.currentShader._setNoiseUVRect( + source.width || source.videoWidth || 1, + source.height || source.videoHeight || 1, + w, + h, + Math.min(u0, u1), + Math.min(v0, v1), + ); + } } let normalTextureId = -1; @@ -539,6 +551,9 @@ export default class LitQuadBatcher extends QuadBatcher { this.boundTextures[0] = source; shader.setUniform("uSampler", 0); + // `noise_uv` builtin: a blit is a full-frame quad — identity rect + shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // transform corners through the renderer transform — see // `QuadBatcher.blitTexture` for the rationale. Only caller today // is `WebGLRenderer.blitEffect`, which resets `currentTransform` diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index a2f37b96f..4b544df3e 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -220,6 +220,9 @@ export default class QuadBatcher extends MaterialBatcher { this.boundTextures[0] = source; shader.setUniform("uSampler", 0); + // `noise_uv` builtin: a blit is a full-frame quad — identity rect + shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // (re)bind any extra textures a ShaderEffect declared via setTexture, // to their reserved high units — after the source claims unit 0 shader._prepareTextures?.(this); @@ -310,6 +313,19 @@ export default class QuadBatcher extends MaterialBatcher { this.currentShader.setUniform("uSampler", unit); this.currentSamplerUnit = unit; } + // feed the effect's `noise_uv` builtin (frame-local UVs) with this + // quad's frame rect — min() normalizes flipped (swapped) UVs + if (typeof this.currentShader._setNoiseUVRect === "function") { + const source = texture.getTexture(); + this.currentShader._setNoiseUVRect( + source.width || source.videoWidth || 1, + source.height || source.videoHeight || 1, + w, + h, + Math.min(u0, u1), + Math.min(v0, v1), + ); + } } // Transform vertices. Stamp per-sprite depth onto z BEFORE diff --git a/packages/melonjs/src/video/webgl/shadereffect.js b/packages/melonjs/src/video/webgl/shadereffect.js index 080ebacf4..d1f48d417 100644 --- a/packages/melonjs/src/video/webgl/shadereffect.js +++ b/packages/melonjs/src/video/webgl/shadereffect.js @@ -8,6 +8,112 @@ import Texture2d from "../texture/texture2d.ts"; import GLShader from "./glshader.js"; import quadVertex from "./shaders/quad.vert"; +/* + * ---- Shader builtins ------------------------------------------------------- + * + * Inside a ShaderEffect fragment body, three names get special treatment so + * users never compute screen/frame UVs by hand: + * + * - `uniform sampler2D : screen_texture;` — the engine strips the + * annotation and keeps the sampler filled with a capture of everything + * drawn so far (a back-buffer copy, via the renderer's shared + * toFrameTexture slot). An optional wrap mode is accepted: + * `: screen_texture(repeat)`. + * - `screen_uv` — varying with this fragment's position in that capture + * (0..1 across the screen). + * - `noise_uv` — varying with a frame-local coordinate across the drawn + * object (undoes atlas packing; scaled to object pixels so patterns keep + * their density when the destination is scaled). + * + * The builtins only activate when referenced, and never when the body + * carries its OWN declaration of the identifier — a shader that used these + * names before this feature keeps compiling unchanged. + */ +const SCREEN_TEXTURE_ANNOTATION = + /\buniform\s+sampler2D\s+([A-Za-z_]\w*)\s*:\s*screen_texture(?:\((repeat|repeat-x|repeat-y|no-repeat)\))?\s*;/g; +const SCREEN_UV_IDENTIFIER = /\bscreen_uv\b/; +const NOISE_UV_IDENTIFIER = /\bnoise_uv\b/; + +/** + * whether the body declares `name` itself (as a varying/uniform/attribute) — + * the engine then leaves that identifier fully user-managed + * @ignore + */ +function hasOwnDeclaration(source, name) { + return new RegExp( + `\\b(?:varying|uniform|attribute)\\s+\\w+\\s+${name}\\s*;`, + ).test(source); +} + +/** + * Parse the builtin usages out of a fragment body: collect and strip the + * `: screen_texture` annotations, and detect the `screen_uv` / `noise_uv` + * varyings. Bodies without builtins pass through byte-identical. + * @ignore + */ +function parseShaderBuiltins(fragmentBody) { + const screenTextures = []; + const body = fragmentBody.replace( + SCREEN_TEXTURE_ANNOTATION, + (match, name, repeat = "no-repeat") => { + screenTextures.push({ name, repeat }); + // keep the plain `uniform sampler2D ;` declaration + return match.replace(/\s*:\s*screen_texture(?:\([\w-]+\))?/, ""); + }, + ); + const screenUV = + (screenTextures.length > 0 || SCREEN_UV_IDENTIFIER.test(body)) && + !hasOwnDeclaration(body, "screen_uv"); + const noiseUV = + NOISE_UV_IDENTIFIER.test(body) && !hasOwnDeclaration(body, "noise_uv"); + return { body, screenTextures, screenUV, noiseUV }; +} + +/** + * The effect's vertex shader: the stock quad template, or — when a builtin + * varying is in play — a variant that additionally computes `screen_uv` + * (clip space → 0..1) and/or `noise_uv` (atlas UV → frame-local, fed by + * `_setNoiseUVRect` through the `ME_*` uniforms). Built deterministically + * from a template; user source is never rewritten. + * @ignore + */ +function buildEffectVertex(builtins) { + if (!builtins.screenUV && !builtins.noiseUV) { + return quadVertex; + } + return [ + "attribute vec3 aVertex;", + "attribute vec2 aRegion;", + "attribute vec4 aColor;", + "uniform mat4 uProjectionMatrix;", + "varying vec2 vRegion;", + "varying vec4 vColor;", + ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), + ...(builtins.noiseUV + ? [ + "uniform vec2 ME_size_obj;", + "uniform vec2 ME_size_img;", + "uniform vec2 ME_offset;", + "varying vec2 noise_uv;", + ] + : []), + "void main(void) {", + " vec4 ME_clip = uProjectionMatrix * vec4(aVertex, 1.0);", + " gl_Position = ME_clip;", + ...(builtins.screenUV + ? [" screen_uv = ME_clip.xy / ME_clip.w * 0.5 + 0.5;"] + : []), + ...(builtins.noiseUV + ? [ + " noise_uv = aRegion * (ME_size_img / ME_size_obj) - ME_offset / ME_size_obj;", + ] + : []), + " vColor = vec4(aColor.bgr * aColor.a, aColor.a);", + " vRegion = aRegion;", + "}", + ].join("\n"); +} + /** * A simplified shader class for applying custom fragment effects to renderables. * Only requires a fragment `apply()` function — the vertex shader, uniforms, and @@ -34,6 +140,23 @@ import quadVertex from "./shaders/quad.vert"; * mySprite.shader = pulse; * // update the uniform each frame * pulse.setUniform("uTime", time); + * @example + * // Shader builtins — no JS plumbing, no UV math: + * // `: screen_texture` keeps the sampler filled with everything drawn so + * // far (a back-buffer copy; one screen copy per draw of the effect), + * // `screen_uv` is this fragment's 0..1 screen position, and `noise_uv` + * // runs 0..1 across the sprite regardless of its atlas frame. + * const water = new ShaderEffect(renderer, ` + * uniform sampler2D uNoise; + * uniform sampler2D screenTex : screen_texture; + * uniform float uTime; + * vec4 apply(vec4 color, vec2 uv) { + * vec2 flow = texture2D(uNoise, noise_uv + uTime * 0.25).rg; + * vec4 refracted = texture2D(screenTex, screen_uv + flow * 0.005); + * return refracted * texture2D(uSampler, uv + flow * 0.005); + * } + * `); + * water.setTexture("uNoise", noiseTexture.getTexture(), "repeat"); */ export default class ShaderEffect { /** @@ -102,12 +225,29 @@ export default class ShaderEffect { return; } + // Shader builtins: parse & strip `: screen_texture` annotations, + // detect the free `screen_uv` / `noise_uv` varyings (see the module + // header). A body using none of them passes through untouched. + const builtins = parseShaderBuiltins(fragmentBody); + + /** + * samplers annotated `: screen_texture` — the renderer checks this to + * know when to refresh the shared frame capture before the effect draws + * @type {Array<{name: string, repeat: string}>} + * @ignore + */ + this._screenTextureUniforms = builtins.screenTextures; + /** @ignore */ + this._hasNoiseUV = builtins.noiseUV; + // wrap the user's apply() with the texture-sampling boilerplate const fragment = [ "uniform sampler2D uSampler;", "varying vec4 vColor;", "varying vec2 vRegion;", - fragmentBody, + ...(builtins.screenUV ? ["varying vec2 screen_uv;"] : []), + ...(builtins.noiseUV ? ["varying vec2 noise_uv;"] : []), + builtins.body, "void main(void) {", " vec4 texColor = texture2D(uSampler, vRegion) * vColor;", " gl_FragColor = apply(texColor, vRegion);", @@ -117,7 +257,7 @@ export default class ShaderEffect { /** @ignore */ this._shader = new GLShader( renderer.gl, - quadVertex, + buildEffectVertex(builtins), fragment, precision || renderer.shaderPrecision, ); @@ -131,6 +271,17 @@ export default class ShaderEffect { */ this._extraTextures = new Map(); + // wire every annotated screen sampler to the renderer's shared frame + // capture — a live GPU-resident entry, re-bound fresh on each draw and + // skipped while no capture has been taken yet + for (const screenTexture of builtins.screenTextures) { + this.setTexture( + screenTexture.name, + renderer.getSharedFrameTexture(), + screenTexture.repeat, + ); + } + // flip enabled across context loss so beginPostEffect skips us on(ONCONTEXT_LOST, this._onContextLost, this); on(ONCONTEXT_RESTORED, this._onContextRestored, this); @@ -229,6 +380,94 @@ export default class ShaderEffect { return this; } + /** + * Feed the `noise_uv` builtin's frame rect for the object about to draw: + * the source texture dimensions, the destination (object) dimensions, and + * the frame's top-left UV origin. Called by the quad batchers per draw; + * a no-op unless this effect's fragment actually uses `noise_uv` (the + * `ME_*` uniforms only exist — and are only active — then). + * @param {number} sourceWidth - source texture width in pixels + * @param {number} sourceHeight - source texture height in pixels + * @param {number} objectWidth - destination (object) width in pixels + * @param {number} objectHeight - destination (object) height in pixels + * @param {number} u0 - the frame's left UV coordinate (flip-normalized) + * @param {number} v0 - the frame's top UV coordinate (flip-normalized) + * @ignore + */ + _setNoiseUVRect( + sourceWidth, + sourceHeight, + objectWidth, + objectHeight, + u0, + v0, + ) { + if ( + this._hasNoiseUV !== true || + typeof this._shader === "undefined" || + this.destroyed === true || + this._shader.suspended + ) { + return; + } + // guard each set on the ACTIVE uniforms map — the compiler eliminates + // the ME_* uniforms when `noise_uv` ends up unused, and setUniform + // throws on inactive uniforms + const uniforms = this._shader.uniforms; + if (typeof uniforms.ME_size_obj !== "undefined") { + this._shader.setUniform("ME_size_obj", [ + Math.max(Math.abs(objectWidth), 1), + Math.max(Math.abs(objectHeight), 1), + ]); + } + if (typeof uniforms.ME_size_img !== "undefined") { + this._shader.setUniform("ME_size_img", [ + Math.max(sourceWidth, 1), + Math.max(sourceHeight, 1), + ]); + } + if (typeof uniforms.ME_offset !== "undefined") { + this._shader.setUniform("ME_offset", [ + u0 * Math.max(sourceWidth, 1), + v0 * Math.max(sourceHeight, 1), + ]); + } + } + + /** + * Apply the wrap mode a `: screen_texture()` annotation asked for + * onto the live capture handle. Captures are NPOT (canvas-sized), which + * WebGL 1 cannot repeat — warn once and keep the clamp there. The unit is + * force-activated first: the batcher's bind may have been skipped as + * redundant while the GL active unit points elsewhere. + * @ignore + */ + _applyCaptureWrap(batcher, glTex, entry) { + const gl = batcher.gl; + if (batcher.renderer.WebGLVersion === 1) { + if (this._captureWrapWarned !== true) { + this._captureWrapWarned = true; + console.warn( + "ShaderEffect: screen_texture(" + + entry.repeat + + ") needs WebGL 2 (captures are non-power-of-two) — using clamp-to-edge", + ); + } + return; + } + gl.activeTexture(gl.TEXTURE0 + entry.unit); + gl.bindTexture(gl.TEXTURE_2D, glTex); + batcher.currentTextureUnit = entry.unit; + const wrapS = /^repeat(-x)?$/.test(entry.repeat) + ? gl.REPEAT + : gl.CLAMP_TO_EDGE; + const wrapT = /^repeat(-y)?$/.test(entry.repeat) + ? gl.REPEAT + : gl.CLAMP_TO_EDGE; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT); + } + /** * Bind an **extra** texture to a named `sampler2D` uniform in this shader, so * a custom effect can read a *second* texture — a noise map, mask, gradient, @@ -362,6 +601,11 @@ export default class ShaderEffect { const glTex = entry.image.glTexture; if (glTex !== null && typeof glTex !== "undefined") { batcher.bindTexture2D(glTex, entry.unit, false); + if (entry.repeat !== "no-repeat") { + // `: screen_texture(repeat)` — captures are created + // clamped; apply the requested wrap on the live handle + this._applyCaptureWrap(batcher, glTex, entry); + } this._shader.setUniform(name, entry.unit); bound = true; } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index ab34df72a..0272565e6 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -837,13 +837,12 @@ export default class WebGLRenderer extends Renderer { * * The capture reflects the framebuffer **at the call site** (a `flush()` * runs first), so call it right before drawing the surface that needs the - * backdrop — the Godot `BackBufferCopy` placement model. It reads whichever + * backdrop — the classic back-buffer-copy placement model. It reads whichever * framebuffer is currently bound: the backbuffer normally, or a camera's * post-effect FBO during that pass — so it works under both `Camera2d` and * `Camera3d`. Under a perspective camera, "drawn so far" is draw order, not * depth order: capture after the opaque scene, just before the refracting - * surface (same guidance as Unity `_CameraOpaqueTexture` / Godot screen - * texture). + * surface (the usual opaque-scene-texture guidance). * * Feed the result straight into a custom post-effect: * ```js @@ -936,7 +935,7 @@ export default class WebGLRenderer extends Renderer { // - `target: ` → refresh that caller-owned capture in place const shared = typeof options.target === "undefined"; let frame = shared - ? this._frameTexture + ? this.getSharedFrameTexture() : options.target === null ? undefined : options.target; @@ -1001,6 +1000,23 @@ export default class WebGLRenderer extends Renderer { return frame; } + /** + * The renderer-owned shared frame-capture slot, minted lazily so it has a + * stable identity from the moment an effect is constructed — before the + * first actual capture fills it (`glTexture` stays `null` until then, and + * the live-sampler bind path skips unfilled captures). `ShaderEffect` + * wires `: screen_texture` annotated samplers to this object. + * @returns {FrameTexture} the shared capture slot + * @ignore + */ + getSharedFrameTexture() { + if (typeof this._frameTexture === "undefined") { + const canvas = this.getCanvas(); + this._frameTexture = new FrameTexture(this, canvas.width, canvas.height); + } + return this._frameTexture; + } + /** * Forget the cached binding for GL texture `unit` on every batcher (except * `except`). Call this whenever a texture is bound to a unit **directly** — @@ -1112,6 +1128,20 @@ export default class WebGLRenderer extends Renderer { const h = canvas.height; this.flush(); + + // `screen_texture` shader builtin (back-buffer copy semantics): + // refresh the shared frame capture the effects' live samplers are + // wired to. For a CAMERA effect the "screen" is the scene itself — + // capture the still-bound camera FBO before unbinding it. For a + // renderable's effect chain the "screen" is everything drawn behind + // it — captured from the parent target after it is rebound below. + const needsScreenTexture = effects.some((fx) => { + return fx._screenTextureUniforms?.length > 0; + }); + if (needsScreenTexture && isCamera) { + this.toFrameTexture(); + } + rt1.unbind(); // get parent render target for rebinding after blits @@ -1138,6 +1168,11 @@ export default class WebGLRenderer extends Renderer { this.setViewport(0, 0, w, h); emit(RENDER_TARGET_CHANGED, this); + // see needsScreenTexture above — the scene behind this renderable + if (needsScreenTexture && !isCamera) { + this.toFrameTexture(); + } + if (effects.length === 1) { this.blitEffect(rt1.texture, 0, 0, w, h, effects[0], keepBlend); } else { @@ -1439,6 +1474,15 @@ export default class WebGLRenderer extends Renderer { // active lights AND this sprite has a normal map. Otherwise the // unlit `quad` batcher is faster (full texture-unit capacity, no // paired normal upload, no per-fragment lighting math). + // Back-buffer copy semantics for the `screen_texture` shader + // builtin: refresh the shared frame capture with everything drawn so + // far BEFORE this sprite's own quad, so the effect samples the scene + // behind it. Must run before setBatcher below — toFrameTexture + // flushes and switches to the quad batcher internally. + if (this.customShader?._screenTextureUniforms?.length > 0) { + this.toFrameTexture(); + } + const useLit = this.batchers.has("litQuad") && this.activeLightCount > 0 && diff --git a/packages/melonjs/tests/shader-loader.spec.js b/packages/melonjs/tests/shader-loader.spec.js index 5d4f26c7e..155542f7c 100644 --- a/packages/melonjs/tests/shader-loader.spec.js +++ b/packages/melonjs/tests/shader-loader.spec.js @@ -399,4 +399,164 @@ describe("shader assets + clone under context loss", () => { expect(loader.unload({ name: "flash-cycle", type: "shader" })).toBe(true); expect(fx.destroyed).toBe(true); }); + + /* + * {vertex, fragment} program pairs → a raw, shared GLShader. Each test + * is adversarial: it pins a behavior a plausible implementation + * shortcut would break (wrong return type, swapped sources, cache + * clobbering, half-registered failures, missing destroy). + */ + describe("{vertex, fragment} program pairs", () => { + // distinct, actively-used uniforms in EACH stage — proves both + // sources reached the right stage of the linked program (swapped + // vertex/fragment would fail to compile: attributes are + // vertex-only) + const PAIR_VERTEX = [ + "attribute vec3 aVertex;", + "uniform mat4 uProjectionMatrix;", + "uniform float uVertOnly;", + "void main(void) {", + " gl_Position = uProjectionMatrix * vec4(aVertex.xy, aVertex.z + uVertOnly, 1.0);", + "}", + ].join("\n"); + const PAIR_FRAGMENT = [ + "uniform float uFragOnly;", + "void main(void) {", + " gl_FragColor = vec4(uFragOnly, 0.0, 0.0, 1.0);", + "}", + ].join("\n"); + + it("inline pair compiles into a shared raw GLShader — not a ShaderEffect", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "pair-inline", + type: "shader", + data: { vertex: PAIR_VERTEX, fragment: PAIR_FRAGMENT }, + }); + + const shader = loader.getShader("pair-inline"); + expect(shader).toBeInstanceOf(GLShader); + expect(shader).not.toBeInstanceOf(ShaderEffect); + expect(shader.shared).toBe(true); + // both stages' uniforms are active on the linked program — the + // sources landed in the RIGHT stages + expect(typeof shader.uniforms.uVertOnly).not.toBe("undefined"); + expect(typeof shader.uniforms.uFragOnly).not.toBe("undefined"); + // shared semantics: same instance on every call + expect(loader.getShader("pair-inline")).toBe(shader); + + expect(loader.unload({ name: "pair-inline", type: "shader" })).toBe(true); + expect(shader.destroyed).toBe(true); + expect(loader.getShader("pair-inline")).toBe(null); + }); + + it("src pair fetches BOTH URLs and compiles them into their stages", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "pair-src", + type: "shader", + src: { + vertex: `data:text/plain,${encodeURIComponent(PAIR_VERTEX)}`, + fragment: `data:text/plain,${encodeURIComponent(PAIR_FRAGMENT)}`, + }, + }); + + const shader = loader.getShader("pair-src"); + expect(shader).toBeInstanceOf(GLShader); + expect(typeof shader.uniforms.uVertOnly).not.toBe("undefined"); + expect(typeof shader.uniforms.uFragOnly).not.toBe("undefined"); + + loader.unload({ name: "pair-src", type: "shader" }); + }); + + it("a second load of the same name keeps the first instance (no clobber)", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "pair-dup", + type: "shader", + data: { vertex: PAIR_VERTEX, fragment: PAIR_FRAGMENT }, + }); + const first = loader.getShader("pair-dup"); + await loader.load({ + name: "pair-dup", + type: "shader", + data: { vertex: PAIR_VERTEX, fragment: PAIR_FRAGMENT }, + }); + expect(loader.getShader("pair-dup")).toBe(first); + expect(first.destroyed).toBe(false); + + loader.unload({ name: "pair-dup", type: "shader" }); + }); + + it("clone() yields a caller-owned (non-shared) GLShader copy", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await loader.load({ + name: "pair-clone", + type: "shader", + data: { vertex: PAIR_VERTEX, fragment: PAIR_FRAGMENT }, + }); + const shader = loader.getShader("pair-clone"); + const copy = shader.clone(); + expect(copy).toBeInstanceOf(GLShader); + expect(copy).not.toBe(shader); + expect(copy.shared).toBe(false); + // independent programs: destroying the copy leaves the shared one + copy.destroy(); + expect(shader.destroyed).toBe(false); + + loader.unload({ name: "pair-clone", type: "shader" }); + }); + + it("rejects a pair missing one source, with the asset name — and registers nothing", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await expect( + loader.load({ + name: "pair-halved", + type: "shader", + src: { fragment: "data:text/plain,void%20main(void)%20%7B%7D" }, + }), + ).rejects.toThrow(/pair-halved.*vertex/); + // a failed load must not leave a half-registered cache entry + expect(loader.getShader("pair-halved")).toBe(null); + + await expect( + loader.load({ + name: "pair-halved-inline", + type: "shader", + data: { vertex: PAIR_VERTEX }, + }), + ).rejects.toThrow(/pair-halved-inline/); + expect(loader.getShader("pair-halved-inline")).toBe(null); + }); + + it("rejects invalid GLSL in a pair, with the asset name — and registers nothing", async (ctx) => { + if (!isWebGL) { + ctx.skip(); + return; + } + await expect( + loader.load({ + name: "pair-broken", + type: "shader", + data: { vertex: "not glsl", fragment: PAIR_FRAGMENT }, + }), + ).rejects.toThrow(/pair-broken/); + expect(loader.getShader("pair-broken")).toBe(null); + }); + }); }); diff --git a/packages/melonjs/tests/shadereffect-builtins.spec.js b/packages/melonjs/tests/shadereffect-builtins.spec.js new file mode 100644 index 000000000..d5ea6d95c --- /dev/null +++ b/packages/melonjs/tests/shadereffect-builtins.spec.js @@ -0,0 +1,391 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { boot, ShaderEffect, video, WebGLRenderer } from "../src/index.js"; + +/** + * Shader builtins in ShaderEffect: + * + * - `uniform sampler2D name : screen_texture;` — auto-wired to the + * renderer's shared frame capture (back-buffer copy semantics) + * - `screen_uv` — free varying, fragment position in that capture + * - `noise_uv` — free varying, frame-local 0..1 across the drawn object + * + * Covers source-level parsing/injection, the live auto-wiring, true + * screen-capture semantics on the sprite (customShader) path via + * readPixels, noise_uv frame-locality for an atlas sub-frame, and + * backward compatibility for shaders that don't use (or self-declare) + * the builtin names. + */ +describe("ShaderEffect builtins (screen_texture / screen_uv / noise_uv)", () => { + let renderer; + + beforeAll(async () => { + await boot(); + try { + video.init(64, 64, { + parent: "screen", + renderer: video.WEBGL, + failIfMajorPerformanceCaveat: false, + }); + } catch { + // WebGL genuinely unavailable — tests skip below + } + if (video.renderer instanceof WebGLRenderer) { + renderer = video.renderer; + // a bare harness never runs a frame, which is what normally + // installs the camera's ortho projection — set it up once so + // world coordinates land on canvas pixels like in a real game + renderer.projectionMatrix.ortho(0, 64, 64, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } + }); + + afterAll(() => { + try { + video.init(64, 64, { parent: "screen", renderer: video.AUTO }); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const IDENTITY_SCREEN = ` + uniform sampler2D screenTex : screen_texture; + vec4 apply(vec4 color, vec2 uv) { + return texture2D(screenTex, screen_uv); + } + `; + + it("parses the annotation, compiles, and auto-wires the shared capture", (ctx) => { + requireWebGL(ctx); + const fx = new ShaderEffect(renderer, IDENTITY_SCREEN); + // annotation recorded (with default wrap) + expect(fx._screenTextureUniforms).toEqual([ + { name: "screenTex", repeat: "no-repeat" }, + ]); + // the stripped sampler is a real active uniform of the program + expect(fx._shader.uniforms.screenTex).toBeDefined(); + // auto-wired as a LIVE entry pointing at the shared capture slot + const entry = fx._extraTextures.get("screenTex"); + expect(entry).toBeDefined(); + expect(entry.live).toBe(true); + expect(entry.image).toBe(renderer.getSharedFrameTexture()); + fx.destroy(); + }); + + it("accepts a wrap mode in the annotation", (ctx) => { + requireWebGL(ctx); + const fx = new ShaderEffect( + renderer, + ` + uniform sampler2D screenTex : screen_texture(repeat); + vec4 apply(vec4 color, vec2 uv) { + return texture2D(screenTex, screen_uv * 2.0); + } + `, + ); + expect(fx._screenTextureUniforms).toEqual([ + { name: "screenTex", repeat: "repeat" }, + ]); + expect(fx._extraTextures.get("screenTex").repeat).toBe("repeat"); + fx.destroy(); + }); + + it("activates noise_uv (ME_* uniforms) only when used", (ctx) => { + requireWebGL(ctx); + const withNoise = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return vec4(noise_uv, 0.0, 1.0); + } + `, + ); + expect(withNoise._hasNoiseUV).toBe(true); + expect(withNoise._shader.uniforms.ME_size_obj).toBeDefined(); + expect(withNoise._shader.uniforms.ME_size_img).toBeDefined(); + expect(withNoise._shader.uniforms.ME_offset).toBeDefined(); + withNoise.destroy(); + + const plain = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return color; + } + `, + ); + expect(plain._hasNoiseUV).toBe(false); + expect(plain._screenTextureUniforms).toEqual([]); + expect(plain._shader.uniforms.ME_size_obj).toBeUndefined(); + plain.destroy(); + }); + + it("leaves a body that declares its own builtin names untouched (back-compat)", (ctx) => { + requireWebGL(ctx); + // pre-feature shader that invented its own `noise_uv` uniform — the + // engine must not inject a conflicting varying + const fx = new ShaderEffect( + renderer, + ` + uniform vec2 noise_uv; + vec4 apply(vec4 color, vec2 uv) { + return vec4(noise_uv, 0.0, 1.0) * color; + } + `, + ); + expect(fx._hasNoiseUV).toBe(false); + // user-managed uniform is active and settable, exactly as before + expect(fx._shader.uniforms.noise_uv).toBeDefined(); + expect(() => { + fx.setUniform("noise_uv", [0.5, 0.5]); + }).not.toThrow(); + fx.destroy(); + }); + + // draw `source` as a quad through the single-effect customShader path — + // the same harness as shadereffect-settexture.spec.js + const drawWithEffect = (effect, source, dx, dy, dw, dh, sx, sy, sw, sh) => { + renderer.save(); + renderer.customShader = effect; + renderer.drawImage( + source, + sx ?? 0, + sy ?? 0, + sw ?? source.width, + sh ?? source.height, + dx, + dy, + dw, + dh, + ); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + }; + + const readPixel = (canvasX, canvasY) => { + const gl = renderer.gl; + const px = new Uint8Array(4); + // readPixels rows are bottom-up; canvas coords are top-down + gl.readPixels( + canvasX, + renderer.getCanvas().height - 1 - canvasY, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + px, + ); + return px; + }; + + const solidCanvas = (r, g, b, w = 64, h = 64) => { + const c = video.createCanvas(w, h); + const ctx2d = c.getContext("2d"); + ctx2d.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx2d.fillRect(0, 0, w, h); + return c; + }; + + it("does not capture the screen for effects without screen_texture", (ctx) => { + requireWebGL(ctx); + const spy = vi.spyOn(renderer, "toFrameTexture"); + const plain = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return color; + } + `, + ); + drawWithEffect(plain, solidCanvas(0, 255, 0, 16, 16), 0, 0, 16, 16); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + plain.destroy(); + }); + + it("samples the true scene behind the object (customShader path, readPixels)", (ctx) => { + requireWebGL(ctx); + + // two-tone backdrop: red top half, blue bottom half (canvas coords) + renderer.clearColor("#000000ff"); + renderer.drawImage(solidCanvas(255, 0, 0, 64, 32), 0, 0); + renderer.drawImage(solidCanvas(0, 0, 255, 64, 32), 0, 32); + renderer.flush(); + + // a GRAY source drawn through an effect that returns the SCREEN with + // channels swizzled (.bgr) — output pixels prove all three at once: + // the effect ran (not the gray source), it sampled the true backdrop + // (back-buffer copy), and screen_uv orientation matches (top stays top) + const fx = new ShaderEffect( + renderer, + ` + uniform sampler2D screenTex : screen_texture; + vec4 apply(vec4 color, vec2 uv) { + return vec4(texture2D(screenTex, screen_uv).bgr, 1.0); + } + `, + ); + drawWithEffect(fx, solidCanvas(64, 64, 64, 32, 32), 16, 16, 32, 32); + + // inside the quad, canvas-top region (y 20 < 32): red backdrop, + // swizzled → blue + const top = readPixel(32, 20); + expect(top[2]).toBeGreaterThan(200); + expect(top[0]).toBeLessThan(60); + // inside the quad, canvas-bottom region (y 44): blue backdrop, + // swizzled → red + const bottom = readPixel(32, 44); + expect(bottom[0]).toBeGreaterThan(200); + expect(bottom[2]).toBeLessThan(60); + // outside the quad: backdrop untouched (red stays red) + const outside = readPixel(4, 4); + expect(outside[0]).toBeGreaterThan(200); + + fx.destroy(); + }); + + it("noise_uv is frame-local for an atlas sub-frame", (ctx) => { + requireWebGL(ctx); + + // 64x32 sheet, drawing ONLY its right half (source rect x 32..64) — + // noise_uv must still run 0..1 across the DRAWN frame + const sheet = solidCanvas(0, 0, 0, 64, 32); + const fx = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return vec4(noise_uv, 0.0, 1.0); + } + `, + ); + renderer.clearColor("#000000ff"); + drawWithEffect(fx, sheet, 16, 16, 32, 32, 32, 0, 32, 32); + + // red channel = noise_uv.x: ~0 at the frame's left edge, ~1 at its + // right edge — even though the frame starts at u = 0.5 in the sheet + const left = readPixel(18, 32); + const right = readPixel(45, 32); + expect(left[0]).toBeLessThan(40); + expect(right[0]).toBeGreaterThan(215); + // green channel = noise_uv.y across the frame height + const topEdge = readPixel(32, 18); + const bottomEdge = readPixel(32, 45); + expect(topEdge[1]).toBeLessThan(40); + expect(bottomEdge[1]).toBeGreaterThan(215); + + fx.destroy(); + }); + + it("samples the scene on the CAMERA post-effect (blit) path", (ctx) => { + requireWebGL(ctx); + + // swizzling identity effect on a camera-managed chain: the "screen" + // is the scene captured from the camera FBO before it is unbound + const fx = new ShaderEffect( + renderer, + ` + uniform sampler2D screenTex : screen_texture; + vec4 apply(vec4 color, vec2 uv) { + return vec4(texture2D(screenTex, screen_uv).bgr, 1.0); + } + `, + ); + const spy = vi.spyOn(renderer, "toFrameTexture"); + const camera = { + postEffects: [fx], + _postEffectManaged: true, + isDefault: true, + }; + + renderer.clearColor("#000000ff"); + expect(renderer.beginPostEffect(camera)).toBe(true); + // in the real pipeline the camera re-applies its projection at the + // start of every FBO pass — replay that for the bare harness + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + // the scene, drawn INTO the camera FBO: red top half, blue bottom + renderer.drawImage(solidCanvas(255, 0, 0, 64, 32), 0, 0); + renderer.drawImage(solidCanvas(0, 0, 255, 64, 32), 0, 32); + renderer.endPostEffect(camera); + renderer.flush(); + + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + + // blitted output = the scene swizzled, with orientation preserved + const top = readPixel(32, 12); + expect(top[2]).toBeGreaterThan(200); // red scene → blue out + expect(top[0]).toBeLessThan(60); + const bottom = readPixel(32, 52); + expect(bottom[0]).toBeGreaterThan(200); // blue scene → red out + expect(bottom[2]).toBeLessThan(60); + + fx.destroy(); + }); + + it("samples the scene BEHIND the object on the multi-effect chain path", (ctx) => { + requireWebGL(ctx); + + // two effects on a non-managed renderable force the FBO chain; the + // last one outputs the screen capture — which must be the MAGENTA + // backdrop behind the object, not the object's own (gray) rendering + const passthrough = new ShaderEffect( + renderer, + ` + vec4 apply(vec4 color, vec2 uv) { + return color; + } + `, + ); + const screen = new ShaderEffect( + renderer, + ` + uniform sampler2D screenTex : screen_texture; + vec4 apply(vec4 color, vec2 uv) { + // opaque only where the object drew, sampling the screen there + return vec4(texture2D(screenTex, screen_uv).rgb, color.a); + } + `, + ); + const sprite = { + postEffects: [passthrough, screen], + _postEffectManaged: false, + }; + + renderer.clearColor("#ff00ffff"); // magenta backdrop + expect(renderer.beginPostEffect(sprite)).toBe(true); + // the object: a gray quad in the middle of its capture FBO + renderer.drawImage(solidCanvas(64, 64, 64, 32, 32), 16, 16, 32, 32); + renderer.endPostEffect(sprite); + renderer.flush(); + + // where the object drew, the screen (magenta) shows through — true + // "everything behind me" semantics on the chain path too + const inside = readPixel(32, 32); + expect(inside[0]).toBeGreaterThan(200); + expect(inside[1]).toBeLessThan(60); + expect(inside[2]).toBeGreaterThan(200); + + passthrough.destroy(); + screen.destroy(); + }); + + it("clone keeps the builtins wired", (ctx) => { + requireWebGL(ctx); + const fx = new ShaderEffect(renderer, IDENTITY_SCREEN); + const copy = fx.clone(); + expect(copy._screenTextureUniforms).toEqual(fx._screenTextureUniforms); + const entry = copy._extraTextures.get("screenTex"); + expect(entry.live).toBe(true); + expect(entry.image).toBe(renderer.getSharedFrameTexture()); + // destroying an effect never destroys the renderer-owned capture + fx.destroy(); + copy.destroy(); + expect(renderer.getSharedFrameTexture()).toBeDefined(); + }); +});