diff --git a/public/assets/font/ya-hei-ascii-msdf.json b/public/assets/font/ya-hei-ascii-msdf.json new file mode 100644 index 00000000..2cbc9910 --- /dev/null +++ b/public/assets/font/ya-hei-ascii-msdf.json @@ -0,0 +1 @@ +{"pages":["ya-hei-ascii.png"],"chars":[{"id":124,"index":98,"char":"|","width":8,"height":49,"xoffset":2,"yoffset":1,"xadvance":11,"chnl":15,"x":0,"y":0,"page":0},{"id":106,"index":80,"char":"j","width":16,"height":48,"xoffset":-6,"yoffset":3,"xadvance":11,"chnl":15,"x":0,"y":50,"page":0},{"id":87,"index":61,"char":"W","width":46,"height":36,"xoffset":-1,"yoffset":4,"xadvance":43,"chnl":15,"x":9,"y":0,"page":0},{"id":81,"index":55,"char":"Q","width":35,"height":45,"xoffset":0,"yoffset":4,"xadvance":34,"chnl":15,"x":0,"y":99,"page":0},{"id":36,"index":10,"char":"$","width":22,"height":44,"xoffset":1,"yoffset":0,"xadvance":25,"chnl":15,"x":17,"y":37,"page":0},{"id":40,"index":14,"char":"(","width":14,"height":43,"xoffset":1,"yoffset":4,"xadvance":14,"chnl":15,"x":0,"y":145,"page":0},{"id":41,"index":15,"char":")","width":15,"height":43,"xoffset":-2,"yoffset":4,"xadvance":14,"chnl":15,"x":0,"y":189,"page":0},{"id":91,"index":65,"char":"[","width":12,"height":43,"xoffset":2,"yoffset":4,"xadvance":14,"chnl":15,"x":15,"y":145,"page":0},{"id":93,"index":67,"char":"]","width":12,"height":43,"xoffset":-1,"yoffset":4,"xadvance":14,"chnl":15,"x":0,"y":233,"page":0},{"id":123,"index":97,"char":"{","width":15,"height":43,"xoffset":0,"yoffset":4,"xadvance":14,"chnl":15,"x":0,"y":277,"page":0},{"id":125,"index":99,"char":"}","width":15,"height":43,"xoffset":-1,"yoffset":4,"xadvance":14,"chnl":15,"x":13,"y":233,"page":0},{"id":47,"index":21,"char":"/","width":23,"height":41,"xoffset":-3,"yoffset":4,"xadvance":18,"chnl":15,"x":16,"y":189,"page":0},{"id":92,"index":66,"char":"\\","width":23,"height":41,"xoffset":-3,"yoffset":4,"xadvance":17,"chnl":15,"x":28,"y":145,"page":0},{"id":12385,"index":28668,"char":"ち","width":33,"height":41,"xoffset":3,"yoffset":2,"xadvance":42,"chnl":15,"x":36,"y":82,"page":0},{"id":64,"index":38,"char":"@","width":40,"height":40,"xoffset":2,"yoffset":4,"xadvance":43,"chnl":15,"x":40,"y":37,"page":0},{"id":12435,"index":28718,"char":"ん","width":39,"height":38,"xoffset":1,"yoffset":3,"xadvance":42,"chnl":15,"x":0,"y":321,"page":0},{"id":37,"index":11,"char":"%","width":38,"height":37,"xoffset":0,"yoffset":4,"xadvance":37,"chnl":15,"x":16,"y":277,"page":0},{"id":98,"index":72,"char":"b","width":25,"height":38,"xoffset":2,"yoffset":2,"xadvance":27,"chnl":15,"x":29,"y":231,"page":0},{"id":100,"index":74,"char":"d","width":25,"height":38,"xoffset":0,"yoffset":2,"xadvance":27,"chnl":15,"x":40,"y":187,"page":0},{"id":102,"index":76,"char":"f","width":18,"height":38,"xoffset":-1,"yoffset":2,"xadvance":15,"chnl":15,"x":52,"y":124,"page":0},{"id":103,"index":77,"char":"g","width":25,"height":38,"xoffset":0,"yoffset":13,"xadvance":27,"chnl":15,"x":70,"y":78,"page":0},{"id":104,"index":78,"char":"h","width":23,"height":38,"xoffset":2,"yoffset":2,"xadvance":26,"chnl":15,"x":81,"y":0,"page":0},{"id":107,"index":81,"char":"k","width":23,"height":38,"xoffset":2,"yoffset":2,"xadvance":23,"chnl":15,"x":81,"y":39,"page":0},{"id":108,"index":82,"char":"l","width":8,"height":38,"xoffset":2,"yoffset":2,"xadvance":11,"chnl":15,"x":0,"y":360,"page":0},{"id":112,"index":86,"char":"p","width":25,"height":38,"xoffset":2,"yoffset":13,"xadvance":27,"chnl":15,"x":0,"y":399,"page":0},{"id":113,"index":87,"char":"q","width":25,"height":38,"xoffset":0,"yoffset":13,"xadvance":27,"chnl":15,"x":9,"y":360,"page":0},{"id":12399,"index":28682,"char":"は","width":38,"height":38,"xoffset":3,"yoffset":4,"xadvance":42,"chnl":15,"x":0,"y":438,"page":0},{"id":38,"index":12,"char":"&","width":37,"height":37,"xoffset":1,"yoffset":4,"xadvance":37,"chnl":15,"x":26,"y":399,"page":0},{"id":48,"index":22,"char":"0","width":25,"height":37,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":35,"y":360,"page":0},{"id":51,"index":25,"char":"3","width":23,"height":37,"xoffset":1,"yoffset":4,"xadvance":25,"chnl":15,"x":40,"y":315,"page":0},{"id":54,"index":28,"char":"6","width":24,"height":37,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":61,"y":353,"page":0},{"id":56,"index":30,"char":"8","width":24,"height":37,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":39,"y":437,"page":0},{"id":57,"index":31,"char":"9","width":24,"height":37,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":39,"y":475,"page":0},{"id":63,"index":37,"char":"?","width":19,"height":37,"xoffset":1,"yoffset":4,"xadvance":20,"chnl":15,"x":55,"y":226,"page":0},{"id":67,"index":41,"char":"C","width":28,"height":37,"xoffset":0,"yoffset":4,"xadvance":28,"chnl":15,"x":55,"y":264,"page":0},{"id":71,"index":45,"char":"G","width":30,"height":37,"xoffset":0,"yoffset":4,"xadvance":31,"chnl":15,"x":64,"y":302,"page":0},{"id":77,"index":51,"char":"M","width":37,"height":36,"xoffset":2,"yoffset":4,"xadvance":41,"chnl":15,"x":66,"y":163,"page":0},{"id":79,"index":53,"char":"O","width":34,"height":37,"xoffset":0,"yoffset":4,"xadvance":34,"chnl":15,"x":71,"y":117,"page":0},{"id":83,"index":57,"char":"S","width":24,"height":37,"xoffset":1,"yoffset":4,"xadvance":24,"chnl":15,"x":96,"y":78,"page":0},{"id":105,"index":79,"char":"i","width":9,"height":37,"xoffset":1,"yoffset":3,"xadvance":11,"chnl":15,"x":75,"y":200,"page":0},{"id":109,"index":83,"char":"m","width":37,"height":27,"xoffset":2,"yoffset":13,"xadvance":39,"chnl":15,"x":0,"y":477,"page":0},{"id":121,"index":95,"char":"y","width":26,"height":37,"xoffset":-2,"yoffset":13,"xadvance":22,"chnl":15,"x":84,"y":238,"page":0},{"id":12395,"index":28678,"char":"に","width":37,"height":37,"xoffset":3,"yoffset":4,"xadvance":42,"chnl":15,"x":85,"y":200,"page":0},{"id":33,"index":7,"char":"!","width":9,"height":36,"xoffset":2,"yoffset":4,"xadvance":13,"chnl":15,"x":56,"y":0,"page":0},{"id":49,"index":23,"char":"1","width":22,"height":36,"xoffset":2,"yoffset":4,"xadvance":25,"chnl":15,"x":104,"y":155,"page":0},{"id":50,"index":24,"char":"2","width":24,"height":36,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":106,"y":116,"page":0},{"id":52,"index":26,"char":"4","width":27,"height":36,"xoffset":-2,"yoffset":4,"xadvance":25,"chnl":15,"x":105,"y":0,"page":0},{"id":53,"index":27,"char":"5","width":22,"height":36,"xoffset":2,"yoffset":4,"xadvance":25,"chnl":15,"x":105,"y":37,"page":0},{"id":55,"index":29,"char":"7","width":25,"height":36,"xoffset":0,"yoffset":4,"xadvance":25,"chnl":15,"x":121,"y":74,"page":0},{"id":65,"index":39,"char":"A","width":33,"height":36,"xoffset":-2,"yoffset":4,"xadvance":30,"chnl":15,"x":128,"y":37,"page":0},{"id":66,"index":40,"char":"B","width":24,"height":36,"xoffset":2,"yoffset":4,"xadvance":26,"chnl":15,"x":133,"y":0,"page":0},{"id":68,"index":42,"char":"D","width":30,"height":36,"xoffset":2,"yoffset":4,"xadvance":32,"chnl":15,"x":158,"y":0,"page":0},{"id":69,"index":43,"char":"E","width":21,"height":36,"xoffset":2,"yoffset":4,"xadvance":23,"chnl":15,"x":64,"y":391,"page":0},{"id":70,"index":44,"char":"F","width":20,"height":36,"xoffset":2,"yoffset":4,"xadvance":22,"chnl":15,"x":64,"y":428,"page":0},{"id":72,"index":46,"char":"H","width":28,"height":36,"xoffset":2,"yoffset":4,"xadvance":32,"chnl":15,"x":64,"y":465,"page":0},{"id":73,"index":47,"char":"I","width":14,"height":36,"xoffset":-1,"yoffset":4,"xadvance":12,"chnl":15,"x":66,"y":0,"page":0},{"id":74,"index":48,"char":"J","width":16,"height":36,"xoffset":-2,"yoffset":4,"xadvance":17,"chnl":15,"x":85,"y":428,"page":0},{"id":75,"index":49,"char":"K","width":27,"height":36,"xoffset":2,"yoffset":4,"xadvance":27,"chnl":15,"x":93,"y":465,"page":0},{"id":76,"index":50,"char":"L","width":21,"height":36,"xoffset":2,"yoffset":4,"xadvance":22,"chnl":15,"x":86,"y":340,"page":0},{"id":78,"index":52,"char":"N","width":30,"height":36,"xoffset":2,"yoffset":4,"xadvance":34,"chnl":15,"x":86,"y":377,"page":0},{"id":80,"index":54,"char":"P","width":24,"height":36,"xoffset":2,"yoffset":4,"xadvance":26,"chnl":15,"x":102,"y":414,"page":0},{"id":82,"index":56,"char":"R","width":27,"height":36,"xoffset":2,"yoffset":4,"xadvance":27,"chnl":15,"x":121,"y":451,"page":0},{"id":84,"index":58,"char":"T","width":26,"height":36,"xoffset":-1,"yoffset":4,"xadvance":24,"chnl":15,"x":95,"y":276,"page":0},{"id":85,"index":59,"char":"U","width":28,"height":36,"xoffset":2,"yoffset":4,"xadvance":31,"chnl":15,"x":111,"y":238,"page":0},{"id":86,"index":60,"char":"V","width":32,"height":36,"xoffset":-2,"yoffset":4,"xadvance":28,"chnl":15,"x":123,"y":192,"page":0},{"id":88,"index":62,"char":"X","width":30,"height":36,"xoffset":-1,"yoffset":4,"xadvance":27,"chnl":15,"x":127,"y":153,"page":0},{"id":89,"index":63,"char":"Y","width":29,"height":36,"xoffset":-2,"yoffset":4,"xadvance":25,"chnl":15,"x":131,"y":111,"page":0},{"id":90,"index":64,"char":"Z","width":28,"height":36,"xoffset":-1,"yoffset":4,"xadvance":26,"chnl":15,"x":147,"y":74,"page":0},{"id":119,"index":93,"char":"w","width":36,"height":27,"xoffset":-1,"yoffset":13,"xadvance":33,"chnl":15,"x":162,"y":37,"page":0},{"id":116,"index":90,"char":"t","width":18,"height":34,"xoffset":-1,"yoffset":7,"xadvance":16,"chnl":15,"x":189,"y":0,"page":0},{"id":35,"index":9,"char":"#","width":29,"height":33,"xoffset":-1,"yoffset":4,"xadvance":27,"chnl":15,"x":108,"y":313,"page":0},{"id":59,"index":33,"char":";","width":11,"height":33,"xoffset":-1,"yoffset":13,"xadvance":10,"chnl":15,"x":122,"y":275,"page":0},{"id":12371,"index":28654,"char":"こ","width":32,"height":31,"xoffset":5,"yoffset":8,"xadvance":42,"chnl":15,"x":134,"y":275,"page":0},{"id":58,"index":32,"char":":","width":9,"height":28,"xoffset":0,"yoffset":13,"xadvance":10,"chnl":15,"x":108,"y":347,"page":0},{"id":97,"index":71,"char":"a","width":22,"height":28,"xoffset":0,"yoffset":13,"xadvance":23,"chnl":15,"x":117,"y":376,"page":0},{"id":99,"index":73,"char":"c","width":21,"height":28,"xoffset":0,"yoffset":13,"xadvance":21,"chnl":15,"x":118,"y":347,"page":0},{"id":101,"index":75,"char":"e","width":24,"height":28,"xoffset":0,"yoffset":13,"xadvance":24,"chnl":15,"x":138,"y":307,"page":0},{"id":111,"index":85,"char":"o","width":27,"height":28,"xoffset":0,"yoffset":13,"xadvance":27,"chnl":15,"x":140,"y":229,"page":0},{"id":115,"index":89,"char":"s","width":19,"height":28,"xoffset":0,"yoffset":13,"xadvance":19,"chnl":15,"x":156,"y":190,"page":0},{"id":110,"index":84,"char":"n","width":23,"height":27,"xoffset":2,"yoffset":13,"xadvance":26,"chnl":15,"x":158,"y":148,"page":0},{"id":114,"index":88,"char":"r","width":16,"height":27,"xoffset":2,"yoffset":13,"xadvance":16,"chnl":15,"x":161,"y":111,"page":0},{"id":117,"index":91,"char":"u","width":23,"height":27,"xoffset":1,"yoffset":13,"xadvance":26,"chnl":15,"x":127,"y":405,"page":0},{"id":118,"index":92,"char":"v","width":26,"height":27,"xoffset":-2,"yoffset":13,"xadvance":22,"chnl":15,"x":176,"y":65,"page":0},{"id":120,"index":94,"char":"x","width":24,"height":27,"xoffset":-1,"yoffset":13,"xadvance":21,"chnl":15,"x":199,"y":35,"page":0},{"id":122,"index":96,"char":"z","width":23,"height":27,"xoffset":-1,"yoffset":13,"xadvance":21,"chnl":15,"x":208,"y":0,"page":0},{"id":60,"index":34,"char":"<","width":23,"height":26,"xoffset":4,"yoffset":12,"xadvance":31,"chnl":15,"x":178,"y":93,"page":0},{"id":62,"index":36,"char":">","width":23,"height":26,"xoffset":4,"yoffset":12,"xadvance":31,"chnl":15,"x":178,"y":120,"page":0},{"id":126,"index":100,"char":"~","width":26,"height":11,"xoffset":3,"yoffset":19,"xadvance":31,"chnl":15,"x":158,"y":176,"page":0},{"id":43,"index":17,"char":"+","width":25,"height":25,"xoffset":3,"yoffset":12,"xadvance":31,"chnl":15,"x":182,"y":147,"page":0},{"id":61,"index":35,"char":"=","width":25,"height":17,"xoffset":3,"yoffset":17,"xadvance":31,"chnl":15,"x":127,"y":433,"page":0},{"id":94,"index":68,"char":"^","width":25,"height":23,"xoffset":3,"yoffset":4,"xadvance":31,"chnl":15,"x":121,"y":488,"page":0},{"id":95,"index":69,"char":"_","width":23,"height":7,"xoffset":-2,"yoffset":40,"xadvance":19,"chnl":15,"x":0,"y":505,"page":0},{"id":42,"index":16,"char":"*","width":20,"height":20,"xoffset":0,"yoffset":4,"xadvance":19,"chnl":15,"x":147,"y":488,"page":0},{"id":45,"index":19,"char":"-","width":16,"height":7,"xoffset":1,"yoffset":22,"xadvance":18,"chnl":15,"x":71,"y":155,"page":0},{"id":44,"index":18,"char":",","width":10,"height":15,"xoffset":-1,"yoffset":31,"xadvance":10,"chnl":15,"x":84,"y":276,"page":0},{"id":34,"index":8,"char":"\"","width":14,"height":14,"xoffset":2,"yoffset":4,"xadvance":18,"chnl":15,"x":36,"y":124,"page":0},{"id":39,"index":13,"char":"'","width":8,"height":14,"xoffset":1,"yoffset":4,"xadvance":11,"chnl":15,"x":66,"y":200,"page":0},{"id":96,"index":70,"char":"`","width":13,"height":11,"xoffset":0,"yoffset":2,"xadvance":12,"chnl":15,"x":52,"y":163,"page":0},{"id":46,"index":20,"char":".","width":9,"height":9,"xoffset":0,"yoffset":31,"xadvance":10,"chnl":15,"x":156,"y":219,"page":0},{"id":32,"index":3,"char":" ","width":0,"height":0,"xoffset":-2,"yoffset":36,"xadvance":12,"chnl":15,"x":26,"y":437,"page":0}],"info":{"face":"ya-hei-ascii","size":42,"bold":0,"italic":0,"charset":[" ","!","\"","#","$","%","&","'","(",")","*","+",",","-",".","/","0","1","2","3","4","5","6","7","8","9",":",";","<","=",">","?","@","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","[","\\","]","^","_","`","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","{","|","}","~","こ","ん","に","ち","は"],"unicode":1,"stretchH":100,"smooth":1,"aa":1,"padding":[2,2,2,2],"spacing":[0,0]},"common":{"lineHeight":45,"base":36,"scaleW":512,"scaleH":512,"pages":1,"packed":0,"alphaChnl":0,"redChnl":0,"greenChnl":0,"blueChnl":0},"distanceField":{"fieldType":"msdf","distanceRange":4},"kernings":[{"first":34,"second":114,"amount":-1},{"first":34,"second":115,"amount":-1},{"first":39,"second":114,"amount":-1},{"first":39,"second":115,"amount":-1},{"first":40,"second":106,"amount":5},{"first":42,"second":65,"amount":-4},{"first":42,"second":74,"amount":-3},{"first":42,"second":99,"amount":-2},{"first":42,"second":100,"amount":-2},{"first":42,"second":101,"amount":-2},{"first":42,"second":103,"amount":-2},{"first":42,"second":111,"amount":-2},{"first":42,"second":113,"amount":-2},{"first":65,"second":42,"amount":-3},{"first":65,"second":44,"amount":1},{"first":65,"second":59,"amount":1},{"first":65,"second":67,"amount":-1},{"first":65,"second":71,"amount":-1},{"first":65,"second":74,"amount":2},{"first":65,"second":79,"amount":-1},{"first":65,"second":84,"amount":-3},{"first":65,"second":85,"amount":-1},{"first":65,"second":86,"amount":-3},{"first":65,"second":87,"amount":-2},{"first":65,"second":89,"amount":-3},{"first":65,"second":90,"amount":1},{"first":65,"second":116,"amount":-1},{"first":65,"second":118,"amount":-1},{"first":65,"second":119,"amount":-1},{"first":65,"second":121,"amount":-1},{"first":66,"second":84,"amount":-2},{"first":66,"second":89,"amount":-1},{"first":67,"second":63,"amount":0},{"first":67,"second":67,"amount":-1},{"first":67,"second":71,"amount":-1},{"first":67,"second":79,"amount":-1},{"first":67,"second":81,"amount":-1},{"first":68,"second":44,"amount":-3},{"first":68,"second":46,"amount":-3},{"first":68,"second":65,"amount":-1},{"first":68,"second":84,"amount":-2},{"first":68,"second":88,"amount":-1},{"first":68,"second":90,"amount":-1},{"first":69,"second":65,"amount":0},{"first":69,"second":74,"amount":1},{"first":69,"second":84,"amount":0},{"first":69,"second":87,"amount":1},{"first":69,"second":88,"amount":0},{"first":70,"second":44,"amount":-3},{"first":70,"second":46,"amount":-3},{"first":70,"second":65,"amount":-3},{"first":70,"second":74,"amount":-1},{"first":70,"second":83,"amount":-1},{"first":70,"second":84,"amount":0},{"first":70,"second":97,"amount":-2},{"first":70,"second":102,"amount":0},{"first":71,"second":84,"amount":-1},{"first":71,"second":86,"amount":-1},{"first":71,"second":121,"amount":-1},{"first":74,"second":44,"amount":-2},{"first":74,"second":46,"amount":-2},{"first":74,"second":65,"amount":-1},{"first":74,"second":74,"amount":-1},{"first":74,"second":97,"amount":-1},{"first":75,"second":44,"amount":1},{"first":75,"second":59,"amount":1},{"first":75,"second":67,"amount":-2},{"first":75,"second":71,"amount":-2},{"first":75,"second":74,"amount":2},{"first":75,"second":79,"amount":-2},{"first":75,"second":81,"amount":-2},{"first":75,"second":88,"amount":1},{"first":75,"second":90,"amount":1},{"first":75,"second":99,"amount":-1},{"first":75,"second":100,"amount":-1},{"first":75,"second":101,"amount":-1},{"first":75,"second":103,"amount":-1},{"first":75,"second":111,"amount":-1},{"first":75,"second":113,"amount":-1},{"first":75,"second":116,"amount":-1},{"first":75,"second":118,"amount":-2},{"first":75,"second":119,"amount":-1},{"first":75,"second":121,"amount":-2},{"first":76,"second":42,"amount":-5},{"first":76,"second":63,"amount":-2},{"first":76,"second":65,"amount":1},{"first":76,"second":67,"amount":-1},{"first":76,"second":71,"amount":-1},{"first":76,"second":74,"amount":2},{"first":76,"second":79,"amount":-2},{"first":76,"second":81,"amount":-2},{"first":76,"second":84,"amount":-3},{"first":76,"second":85,"amount":-1},{"first":76,"second":86,"amount":-3},{"first":76,"second":87,"amount":-1},{"first":76,"second":89,"amount":-3},{"first":76,"second":90,"amount":1},{"first":76,"second":116,"amount":-1},{"first":76,"second":118,"amount":-2},{"first":76,"second":119,"amount":-1},{"first":76,"second":121,"amount":-2},{"first":79,"second":44,"amount":-2},{"first":79,"second":46,"amount":-2},{"first":79,"second":65,"amount":-1},{"first":79,"second":74,"amount":0},{"first":79,"second":84,"amount":-2},{"first":79,"second":88,"amount":-1},{"first":79,"second":89,"amount":-1},{"first":79,"second":90,"amount":-1},{"first":80,"second":44,"amount":-7},{"first":80,"second":46,"amount":-7},{"first":80,"second":65,"amount":-4},{"first":80,"second":71,"amount":0},{"first":80,"second":74,"amount":-3},{"first":80,"second":87,"amount":1},{"first":80,"second":88,"amount":-1},{"first":80,"second":97,"amount":-1},{"first":80,"second":99,"amount":-2},{"first":80,"second":100,"amount":-2},{"first":80,"second":101,"amount":-2},{"first":80,"second":103,"amount":-2},{"first":80,"second":111,"amount":-2},{"first":80,"second":113,"amount":-2},{"first":81,"second":44,"amount":-2},{"first":81,"second":46,"amount":-3},{"first":81,"second":65,"amount":-1},{"first":81,"second":84,"amount":-2},{"first":81,"second":88,"amount":-1},{"first":81,"second":89,"amount":0},{"first":81,"second":90,"amount":-1},{"first":82,"second":59,"amount":2},{"first":82,"second":67,"amount":-1},{"first":82,"second":71,"amount":-1},{"first":82,"second":74,"amount":1},{"first":82,"second":79,"amount":0},{"first":82,"second":81,"amount":0},{"first":82,"second":84,"amount":-1},{"first":82,"second":89,"amount":-1},{"first":82,"second":99,"amount":-1},{"first":82,"second":100,"amount":-1},{"first":82,"second":101,"amount":-1},{"first":82,"second":103,"amount":-1},{"first":82,"second":111,"amount":-1},{"first":82,"second":113,"amount":-1},{"first":83,"second":116,"amount":-1},{"first":83,"second":118,"amount":-1},{"first":83,"second":119,"amount":-1},{"first":83,"second":121,"amount":-1},{"first":84,"second":44,"amount":-3},{"first":84,"second":46,"amount":-4},{"first":84,"second":58,"amount":-1},{"first":84,"second":59,"amount":-1},{"first":84,"second":65,"amount":-3},{"first":84,"second":67,"amount":-2},{"first":84,"second":71,"amount":-2},{"first":84,"second":74,"amount":-3},{"first":84,"second":79,"amount":-2},{"first":84,"second":81,"amount":-2},{"first":84,"second":84,"amount":1},{"first":84,"second":86,"amount":1},{"first":84,"second":87,"amount":1},{"first":84,"second":88,"amount":0},{"first":84,"second":89,"amount":1},{"first":84,"second":97,"amount":-5},{"first":84,"second":99,"amount":-5},{"first":84,"second":100,"amount":-5},{"first":84,"second":101,"amount":-5},{"first":84,"second":102,"amount":-2},{"first":84,"second":103,"amount":-5},{"first":84,"second":109,"amount":-4},{"first":84,"second":110,"amount":-4},{"first":84,"second":111,"amount":-5},{"first":84,"second":112,"amount":-4},{"first":84,"second":113,"amount":-5},{"first":84,"second":114,"amount":-4},{"first":84,"second":115,"amount":-3},{"first":84,"second":117,"amount":-4},{"first":84,"second":118,"amount":-2},{"first":84,"second":119,"amount":-3},{"first":84,"second":120,"amount":-4},{"first":84,"second":121,"amount":-3},{"first":84,"second":122,"amount":-3},{"first":85,"second":65,"amount":-1},{"first":86,"second":44,"amount":-5},{"first":86,"second":46,"amount":-5},{"first":86,"second":65,"amount":-3},{"first":86,"second":67,"amount":-1},{"first":86,"second":71,"amount":-1},{"first":86,"second":74,"amount":-2},{"first":86,"second":79,"amount":0},{"first":86,"second":81,"amount":-1},{"first":86,"second":83,"amount":-1},{"first":86,"second":84,"amount":1},{"first":86,"second":97,"amount":-3},{"first":86,"second":99,"amount":-3},{"first":86,"second":100,"amount":-3},{"first":86,"second":101,"amount":-3},{"first":86,"second":103,"amount":-3},{"first":86,"second":109,"amount":-2},{"first":86,"second":110,"amount":-2},{"first":86,"second":111,"amount":-3},{"first":86,"second":112,"amount":-2},{"first":86,"second":113,"amount":-3},{"first":86,"second":114,"amount":-2},{"first":86,"second":115,"amount":-1},{"first":86,"second":117,"amount":-2},{"first":87,"second":44,"amount":-3},{"first":87,"second":46,"amount":-3},{"first":87,"second":65,"amount":-2},{"first":87,"second":84,"amount":1},{"first":87,"second":97,"amount":-2},{"first":87,"second":99,"amount":-1},{"first":87,"second":100,"amount":-1},{"first":87,"second":101,"amount":-1},{"first":87,"second":103,"amount":-1},{"first":87,"second":111,"amount":-1},{"first":87,"second":113,"amount":-1},{"first":88,"second":44,"amount":1},{"first":88,"second":46,"amount":1},{"first":88,"second":59,"amount":2},{"first":88,"second":67,"amount":-1},{"first":88,"second":71,"amount":-1},{"first":88,"second":74,"amount":2},{"first":88,"second":79,"amount":-1},{"first":88,"second":81,"amount":-1},{"first":88,"second":84,"amount":1},{"first":89,"second":44,"amount":-4},{"first":89,"second":46,"amount":-4},{"first":89,"second":65,"amount":-4},{"first":89,"second":67,"amount":-1},{"first":89,"second":71,"amount":-1},{"first":89,"second":74,"amount":-1},{"first":89,"second":79,"amount":-1},{"first":89,"second":81,"amount":-1},{"first":89,"second":83,"amount":-1},{"first":89,"second":84,"amount":1},{"first":89,"second":97,"amount":-4},{"first":89,"second":99,"amount":-4},{"first":89,"second":100,"amount":-4},{"first":89,"second":101,"amount":-4},{"first":89,"second":102,"amount":-1},{"first":89,"second":103,"amount":-4},{"first":89,"second":109,"amount":-3},{"first":89,"second":110,"amount":-3},{"first":89,"second":111,"amount":-4},{"first":89,"second":112,"amount":-3},{"first":89,"second":113,"amount":-4},{"first":89,"second":114,"amount":-3},{"first":89,"second":115,"amount":-3},{"first":89,"second":117,"amount":-3},{"first":90,"second":74,"amount":2},{"first":90,"second":84,"amount":1},{"first":90,"second":121,"amount":-1},{"first":91,"second":106,"amount":5},{"first":98,"second":97,"amount":-1},{"first":98,"second":102,"amount":0},{"first":98,"second":120,"amount":-1},{"first":99,"second":74,"amount":2},{"first":99,"second":84,"amount":-2},{"first":99,"second":89,"amount":-2},{"first":101,"second":34,"amount":-2},{"first":101,"second":39,"amount":-2},{"first":102,"second":41,"amount":3},{"first":102,"second":44,"amount":-3},{"first":102,"second":45,"amount":-2},{"first":102,"second":46,"amount":-3},{"first":102,"second":58,"amount":2},{"first":102,"second":59,"amount":2},{"first":102,"second":63,"amount":1},{"first":102,"second":93,"amount":3},{"first":102,"second":98,"amount":0},{"first":102,"second":104,"amount":0},{"first":102,"second":116,"amount":1},{"first":102,"second":118,"amount":1},{"first":102,"second":119,"amount":1},{"first":102,"second":120,"amount":0},{"first":102,"second":121,"amount":1},{"first":102,"second":125,"amount":2},{"first":103,"second":106,"amount":1},{"first":106,"second":106,"amount":1},{"first":107,"second":44,"amount":2},{"first":107,"second":45,"amount":-3},{"first":107,"second":46,"amount":2},{"first":107,"second":58,"amount":2},{"first":107,"second":59,"amount":2},{"first":107,"second":99,"amount":-1},{"first":107,"second":100,"amount":-1},{"first":107,"second":101,"amount":-1},{"first":107,"second":103,"amount":-1},{"first":107,"second":111,"amount":-1},{"first":107,"second":113,"amount":-1},{"first":107,"second":116,"amount":0},{"first":110,"second":34,"amount":-2},{"first":110,"second":39,"amount":-2},{"first":111,"second":34,"amount":-3},{"first":111,"second":39,"amount":-3},{"first":111,"second":97,"amount":-1},{"first":111,"second":102,"amount":-1},{"first":111,"second":120,"amount":-1},{"first":112,"second":97,"amount":-1},{"first":112,"second":102,"amount":-1},{"first":112,"second":120,"amount":-1},{"first":113,"second":106,"amount":2},{"first":114,"second":44,"amount":-4},{"first":114,"second":45,"amount":-3},{"first":114,"second":46,"amount":-4},{"first":114,"second":58,"amount":2},{"first":114,"second":59,"amount":2},{"first":114,"second":99,"amount":-1},{"first":114,"second":100,"amount":-1},{"first":114,"second":101,"amount":-1},{"first":114,"second":102,"amount":1},{"first":114,"second":103,"amount":-1},{"first":114,"second":109,"amount":0},{"first":114,"second":110,"amount":0},{"first":114,"second":111,"amount":-1},{"first":114,"second":113,"amount":-1},{"first":114,"second":115,"amount":0},{"first":114,"second":116,"amount":1},{"first":114,"second":118,"amount":2},{"first":114,"second":119,"amount":2},{"first":114,"second":120,"amount":1},{"first":114,"second":121,"amount":2},{"first":114,"second":122,"amount":1},{"first":116,"second":45,"amount":-3},{"first":116,"second":63,"amount":-1},{"first":116,"second":99,"amount":-1},{"first":116,"second":100,"amount":-1},{"first":116,"second":101,"amount":0},{"first":116,"second":103,"amount":0},{"first":116,"second":111,"amount":0},{"first":116,"second":113,"amount":0},{"first":116,"second":120,"amount":1},{"first":117,"second":34,"amount":-1},{"first":117,"second":39,"amount":-1},{"first":118,"second":44,"amount":-3},{"first":118,"second":46,"amount":-3},{"first":118,"second":97,"amount":-1},{"first":118,"second":99,"amount":0},{"first":118,"second":100,"amount":0},{"first":118,"second":101,"amount":0},{"first":118,"second":103,"amount":0},{"first":118,"second":111,"amount":0},{"first":118,"second":113,"amount":0},{"first":119,"second":44,"amount":-2},{"first":119,"second":46,"amount":-2},{"first":119,"second":99,"amount":0},{"first":119,"second":100,"amount":0},{"first":119,"second":101,"amount":0},{"first":119,"second":103,"amount":0},{"first":119,"second":111,"amount":0},{"first":119,"second":113,"amount":0},{"first":120,"second":99,"amount":0},{"first":120,"second":100,"amount":0},{"first":120,"second":101,"amount":0},{"first":120,"second":103,"amount":0},{"first":120,"second":111,"amount":0},{"first":120,"second":113,"amount":0},{"first":121,"second":34,"amount":1},{"first":121,"second":39,"amount":1},{"first":121,"second":44,"amount":-2},{"first":121,"second":46,"amount":-3},{"first":121,"second":63,"amount":-2},{"first":121,"second":99,"amount":0},{"first":121,"second":100,"amount":0},{"first":121,"second":101,"amount":0},{"first":121,"second":102,"amount":0},{"first":121,"second":103,"amount":0},{"first":121,"second":111,"amount":0},{"first":121,"second":113,"amount":0},{"first":121,"second":116,"amount":0},{"first":123,"second":106,"amount":4}]} \ No newline at end of file diff --git a/public/assets/font/ya-hei-ascii.png b/public/assets/font/ya-hei-ascii.png new file mode 100644 index 00000000..a23980bb Binary files /dev/null and b/public/assets/font/ya-hei-ascii.png differ diff --git a/sample/textRenderingMsdf/index.html b/sample/textRenderingMsdf/index.html new file mode 100644 index 00000000..5c58503b --- /dev/null +++ b/sample/textRenderingMsdf/index.html @@ -0,0 +1,27 @@ + + + + + + webgpu-samples: textRenderingMsdf + + + + + + + + diff --git a/sample/textRenderingMsdf/main.ts b/sample/textRenderingMsdf/main.ts new file mode 100644 index 00000000..e83e2116 --- /dev/null +++ b/sample/textRenderingMsdf/main.ts @@ -0,0 +1,327 @@ +import { mat4, vec3 } from 'wgpu-matrix'; + +import { + cubeVertexArray, + cubeVertexSize, + cubeUVOffset, + cubePositionOffset, + cubeVertexCount, +} from '../../meshes/cube'; +import { MsdfTextRenderer } from './msdfText'; + +import basicVertWGSL from '../../shaders/basic.vert.wgsl'; +import vertexPositionColorWGSL from '../../shaders/vertexPositionColor.frag.wgsl'; + +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const adapter = await navigator.gpu.requestAdapter(); +const device = await adapter.requestDevice(); + +const context = canvas.getContext('webgpu') as GPUCanvasContext; + +const devicePixelRatio = window.devicePixelRatio || 1; +canvas.width = canvas.clientWidth * devicePixelRatio; +canvas.height = canvas.clientHeight * devicePixelRatio; +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); +const depthFormat = 'depth24plus'; + +context.configure({ + device, + format: presentationFormat, + alphaMode: 'premultiplied', +}); + +const textRenderer = new MsdfTextRenderer( + device, + presentationFormat, + depthFormat +); +const font = await textRenderer.createFont( + new URL( + '../../assets/font/ya-hei-ascii-msdf.json', + import.meta.url + ).toString() +); + +function getTextTransform( + position: [number, number, number], + rotation?: [number, number, number] +) { + const textTransform = mat4.create(); + mat4.identity(textTransform); + mat4.translate(textTransform, position, textTransform); + if (rotation && rotation[0] != 0) { + mat4.rotateX(textTransform, rotation[0], textTransform); + } + if (rotation && rotation[1] != 0) { + mat4.rotateY(textTransform, rotation[1], textTransform); + } + if (rotation && rotation[2] != 0) { + mat4.rotateZ(textTransform, rotation[2], textTransform); + } + return textTransform; +} + +const textTransforms = [ + getTextTransform([0, 0, 1.1]), + getTextTransform([0, 0, -1.1], [0, Math.PI, 0]), + getTextTransform([1.1, 0, 0], [0, Math.PI / 2, 0]), + getTextTransform([-1.1, 0, 0], [0, -Math.PI / 2, 0]), + getTextTransform([0, 1.1, 0], [-Math.PI / 2, 0, 0]), + getTextTransform([0, -1.1, 0], [Math.PI / 2, 0, 0]), +]; + +const titleText = textRenderer.formatText(font, `WebGPU`, { + centered: true, + pixelScale: 1 / 128, +}); +const largeText = textRenderer.formatText( + font, + ` +WebGPU exposes an API for performing operations, such as rendering +and computation, on a Graphics Processing Unit. + +Graphics Processing Units, or GPUs for short, have been essential +in enabling rich rendering and computational applications in personal +computing. WebGPU is an API that exposes the capabilities of GPU +hardware for the Web. The API is designed from the ground up to +efficiently map to (post-2014) native GPU APIs. WebGPU is not related +to WebGL and does not explicitly target OpenGL ES. + +WebGPU sees physical GPU hardware as GPUAdapters. It provides a +connection to an adapter via GPUDevice, which manages resources, and +the device’s GPUQueues, which execute commands. GPUDevice may have +its own memory with high-speed access to the processing units. +GPUBuffer and GPUTexture are the physical resources backed by GPU +memory. GPUCommandBuffer and GPURenderBundle are containers for +user-recorded commands. GPUShaderModule contains shader code. The +other resources, such as GPUSampler or GPUBindGroup, configure the +way physical resources are used by the GPU. + +GPUs execute commands encoded in GPUCommandBuffers by feeding data +through a pipeline, which is a mix of fixed-function and programmable +stages. Programmable stages execute shaders, which are special +programs designed to run on GPU hardware. Most of the state of a +pipeline is defined by a GPURenderPipeline or a GPUComputePipeline +object. The state not included in these pipeline objects is set +during encoding with commands, such as beginRenderPass() or +setBlendConstant().`, + { pixelScale: 1 / 256 } +); + +const text = [ + textRenderer.formatText(font, 'Front', { + centered: true, + pixelScale: 1 / 128, + color: [1, 0, 0, 1], + }), + textRenderer.formatText(font, 'Back', { + centered: true, + pixelScale: 1 / 128, + color: [0, 1, 1, 1], + }), + textRenderer.formatText(font, 'Right', { + centered: true, + pixelScale: 1 / 128, + color: [0, 1, 0, 1], + }), + textRenderer.formatText(font, 'Left', { + centered: true, + pixelScale: 1 / 128, + color: [1, 0, 1, 1], + }), + textRenderer.formatText(font, 'Top', { + centered: true, + pixelScale: 1 / 128, + color: [0, 0, 1, 1], + }), + textRenderer.formatText(font, 'Bottom', { + centered: true, + pixelScale: 1 / 128, + color: [1, 1, 0, 1], + }), + + titleText, + largeText, +]; + +// Create a vertex buffer from the cube data. +const verticesBuffer = device.createBuffer({ + size: cubeVertexArray.byteLength, + usage: GPUBufferUsage.VERTEX, + mappedAtCreation: true, +}); +new Float32Array(verticesBuffer.getMappedRange()).set(cubeVertexArray); +verticesBuffer.unmap(); + +const pipeline = device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: device.createShaderModule({ + code: basicVertWGSL, + }), + buffers: [ + { + arrayStride: cubeVertexSize, + attributes: [ + { + // position + shaderLocation: 0, + offset: cubePositionOffset, + format: 'float32x4', + }, + { + // uv + shaderLocation: 1, + offset: cubeUVOffset, + format: 'float32x2', + }, + ], + }, + ], + }, + fragment: { + module: device.createShaderModule({ + code: vertexPositionColorWGSL, + }), + targets: [ + { + format: presentationFormat, + }, + ], + }, + primitive: { + // Backface culling since the cube is solid piece of geometry. + // Faces pointing away from the camera will be occluded by faces + // pointing toward the camera. + cullMode: 'back', + }, + + // Enable depth testing so that the fragment closest to the camera + // is rendered in front. + depthStencil: { + depthWriteEnabled: true, + depthCompare: 'less', + format: depthFormat, + }, +}); + +const depthTexture = device.createTexture({ + size: [canvas.width, canvas.height], + format: depthFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT, +}); + +const uniformBufferSize = 4 * 16; // 4x4 matrix +const uniformBuffer = device.createBuffer({ + size: uniformBufferSize, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, +}); + +const uniformBindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { + binding: 0, + resource: { + buffer: uniformBuffer, + }, + }, + ], +}); + +const renderPassDescriptor: GPURenderPassDescriptor = { + colorAttachments: [ + { + view: undefined, // Assigned later + + clearValue: [0, 0, 0, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + depthStencilAttachment: { + view: depthTexture.createView(), + + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, +}; + +const aspect = canvas.width / canvas.height; +const projectionMatrix = mat4.perspective((2 * Math.PI) / 5, aspect, 1, 100.0); +const modelViewProjectionMatrix = mat4.create(); + +const start = Date.now(); +function getTransformationMatrix() { + const now = Date.now() / 5000; + const viewMatrix = mat4.identity(); + mat4.translate(viewMatrix, vec3.fromValues(0, 0, -5), viewMatrix); + + const modelMatrix = mat4.identity(); + mat4.translate(modelMatrix, vec3.fromValues(0, 2, -3), modelMatrix); + mat4.rotate( + modelMatrix, + vec3.fromValues(Math.sin(now), Math.cos(now), 0), + 1, + modelMatrix + ); + + // Update the matrix for the cube + mat4.multiply(projectionMatrix, viewMatrix, modelViewProjectionMatrix); + mat4.multiply( + modelViewProjectionMatrix, + modelMatrix, + modelViewProjectionMatrix + ); + + // Update the projection and view matrices for the text + textRenderer.updateCamera(projectionMatrix, viewMatrix); + + // Update the transform of all the text surrounding the cube + const textMatrix = mat4.create(); + for (const [index, transform] of textTransforms.entries()) { + mat4.multiply(modelMatrix, transform, textMatrix); + text[index].setTransform(textMatrix); + } + + // Update the transform of the larger block of text + const crawl = ((Date.now() - start) / 2500) % 14; + mat4.identity(textMatrix); + mat4.rotateX(textMatrix, -Math.PI / 8, textMatrix); + mat4.translate(textMatrix, [0, crawl - 3, 0], textMatrix); + titleText.setTransform(textMatrix); + mat4.translate(textMatrix, [-3, -0.1, 0], textMatrix); + largeText.setTransform(textMatrix); + + return modelViewProjectionMatrix as Float32Array; +} + +function frame() { + const transformationMatrix = getTransformationMatrix(); + device.queue.writeBuffer( + uniformBuffer, + 0, + transformationMatrix.buffer, + transformationMatrix.byteOffset, + transformationMatrix.byteLength + ); + renderPassDescriptor.colorAttachments[0].view = context + .getCurrentTexture() + .createView(); + + const commandEncoder = device.createCommandEncoder(); + const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, uniformBindGroup); + passEncoder.setVertexBuffer(0, verticesBuffer); + passEncoder.draw(cubeVertexCount, 1, 0, 0); + + textRenderer.render(passEncoder, ...text); + + passEncoder.end(); + device.queue.submit([commandEncoder.finish()]); + + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); diff --git a/sample/textRenderingMsdf/meta.ts b/sample/textRenderingMsdf/meta.ts new file mode 100644 index 00000000..d05a5590 --- /dev/null +++ b/sample/textRenderingMsdf/meta.ts @@ -0,0 +1,18 @@ +export default { + name: 'Text Rendering - MSDF', + description: `This example uses multichannel signed distance fields (MSDF) to render text. MSDF +fonts are more complex to implement than using Canvas 2D to generate text, but the resulting +text looks smoother while using less memory than the Canvas 2D approach, especially at high +zoom levels. They can be used to render larger amounts of text efficiently. + +The font texture is generated using [Don McCurdy's MSDF font generation tool](https://msdf-bmfont.donmccurdy.com/)`, + filename: __DIRNAME__, + sources: [ + { path: 'main.ts' }, + { path: 'msdfText.ts' }, + { path: 'msdfText.wgsl' }, + { path: '../../shaders/basic.vert.wgsl' }, + { path: '../../shaders/vertexPositionColor.frag.wgsl' }, + { path: '../../meshes/cube.ts' }, + ], +}; diff --git a/sample/textRenderingMsdf/msdfText.ts b/sample/textRenderingMsdf/msdfText.ts new file mode 100644 index 00000000..065213a7 --- /dev/null +++ b/sample/textRenderingMsdf/msdfText.ts @@ -0,0 +1,513 @@ +import { mat4 } from 'wgpu-matrix'; + +import msdfTextWGSL from './msdfText.wgsl'; + +type Mat4 = mat4.default; + +// The kerning map stores a spare map of character ID pairs with an associated +// X offset that should be applied to the character spacing when the second +// character ID is rendered after the first. +type KerningMap = Map>; + +interface MsdfChar { + id: number; + index: number; + char: string; + width: number; + height: number; + xoffset: number; + yofsset: number; + xadvance: number; + chnl: number; + x: number; + y: number; + page: number; + charIndex: number; +} + +export class MsdfFont { + charCount: number; + defaultChar: MsdfChar; + constructor( + public pipeline: GPURenderPipeline, + public bindGroup: GPUBindGroup, + public lineHeight: number, + public chars: { [x: number]: MsdfChar }, + public kernings: KerningMap + ) { + const charArray = Object.values(chars); + this.charCount = charArray.length; + this.defaultChar = charArray[0]; + } + + getChar(charCode: number): MsdfChar { + let char = this.chars[charCode]; + if (!char) { + char = this.defaultChar; + } + return char; + } + + // Gets the distance in pixels a line should advance for a given character code. If the upcoming + // character code is given any kerning between the two characters will be taken into account. + getXAdvance(charCode: number, nextCharCode: number = -1): number { + const char = this.getChar(charCode); + if (nextCharCode >= 0) { + const kerning = this.kernings.get(charCode); + if (kerning) { + return char.xadvance + (kerning.get(nextCharCode) ?? 0); + } + } + return char.xadvance; + } +} + +export interface MsdfTextMeasurements { + width: number; + height: number; + lineWidths: number[]; + printedCharCount: number; +} + +export class MsdfText { + private bufferArray = new Float32Array(24); + private bufferArrayDirty = true; + + constructor( + public device: GPUDevice, + private renderBundle: GPURenderBundle, + public measurements: MsdfTextMeasurements, + public font: MsdfFont, + public textBuffer: GPUBuffer + ) { + mat4.identity(this.bufferArray); + this.setColor(1, 1, 1, 1); + this.setPixelScale(1 / 512); + this.bufferArrayDirty = true; + } + + getRenderBundle() { + if (this.bufferArrayDirty) { + this.bufferArrayDirty = false; + this.device.queue.writeBuffer( + this.textBuffer, + 0, + this.bufferArray, + 0, + this.bufferArray.length + ); + } + return this.renderBundle; + } + + setTransform(matrix: Mat4) { + mat4.copy(matrix, this.bufferArray); + this.bufferArrayDirty = true; + } + + setColor(r: number, g: number, b: number, a: number = 1.0) { + this.bufferArray[16] = r; + this.bufferArray[17] = g; + this.bufferArray[18] = b; + this.bufferArray[19] = a; + this.bufferArrayDirty = true; + } + + setPixelScale(pixelScale: number) { + this.bufferArray[20] = pixelScale; + this.bufferArrayDirty = true; + } +} + +export interface MsdfTextFormattingOptions { + centered?: boolean; + pixelScale?: number; + color?: [number, number, number, number]; +} + +export class MsdfTextRenderer { + fontBindGroupLayout: GPUBindGroupLayout; + textBindGroupLayout: GPUBindGroupLayout; + pipelinePromise: Promise; + sampler: GPUSampler; + cameraUniformBuffer: GPUBuffer; + + renderBundleDescriptor: GPURenderBundleEncoderDescriptor; + cameraArray: Float32Array = new Float32Array(16 * 2); + + constructor( + public device: GPUDevice, + colorFormat: GPUTextureFormat, + depthFormat: GPUTextureFormat + ) { + this.renderBundleDescriptor = { + colorFormats: [colorFormat], + depthStencilFormat: depthFormat, + }; + + this.sampler = device.createSampler({ + label: 'MSDF text sampler', + minFilter: 'linear', + magFilter: 'linear', + mipmapFilter: 'linear', + maxAnisotropy: 16, + }); + + this.cameraUniformBuffer = device.createBuffer({ + label: 'MSDF camera uniform buffer', + size: this.cameraArray.byteLength, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM, + }); + + this.fontBindGroupLayout = device.createBindGroupLayout({ + label: 'MSDF font group layout', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }, + { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }, + { + binding: 2, + visibility: GPUShaderStage.VERTEX, + buffer: { type: 'read-only-storage' }, + }, + ], + }); + + this.textBindGroupLayout = device.createBindGroupLayout({ + label: 'MSDF text group layout', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.VERTEX, + buffer: {}, + }, + { + binding: 1, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: 'read-only-storage' }, + }, + ], + }); + + const shaderModule = device.createShaderModule({ + label: 'MSDF text shader', + code: msdfTextWGSL, + }); + + this.pipelinePromise = device.createRenderPipelineAsync({ + label: `msdf text pipeline`, + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.fontBindGroupLayout, this.textBindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertexMain', + }, + fragment: { + module: shaderModule, + entryPoint: 'fragmentMain', + targets: [ + { + format: colorFormat, + blend: { + color: { + srcFactor: 'src-alpha', + dstFactor: 'one-minus-src-alpha', + }, + alpha: { + srcFactor: 'one', + dstFactor: 'one', + }, + }, + }, + ], + }, + primitive: { + topology: 'triangle-strip', + stripIndexFormat: 'uint32', + }, + depthStencil: { + depthWriteEnabled: false, + depthCompare: 'less', + format: depthFormat, + }, + }); + } + + async loadTexture(url: string) { + const response = await fetch(url); + const imageBitmap = await createImageBitmap(await response.blob()); + + const texture = this.device.createTexture({ + label: `MSDF font texture ${url}`, + size: [imageBitmap.width, imageBitmap.height, 1], + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + this.device.queue.copyExternalImageToTexture( + { source: imageBitmap }, + { texture }, + [imageBitmap.width, imageBitmap.height] + ); + return texture; + } + + async createFont(fontJsonUrl: string): Promise { + const response = await fetch(fontJsonUrl); + const json = await response.json(); + + const i = fontJsonUrl.lastIndexOf('/'); + const baseUrl = i !== -1 ? fontJsonUrl.substring(0, i + 1) : undefined; + + const pagePromises = []; + for (const pageUrl of json.pages) { + pagePromises.push(this.loadTexture(baseUrl + pageUrl)); + } + + const charCount = json.chars.length; + const charsBuffer = this.device.createBuffer({ + label: 'MSDF character layout buffer', + size: charCount * Float32Array.BYTES_PER_ELEMENT * 8, + usage: GPUBufferUsage.STORAGE, + mappedAtCreation: true, + }); + + const charsArray = new Float32Array(charsBuffer.getMappedRange()); + + const u = 1 / json.common.scaleW; + const v = 1 / json.common.scaleH; + + const chars: { [x: number]: MsdfChar } = {}; + + let offset = 0; + for (const [i, char] of json.chars.entries()) { + chars[char.id] = char; + chars[char.id].charIndex = i; + charsArray[offset] = char.x * u; // texOffset.x + charsArray[offset + 1] = char.y * v; // texOffset.y + charsArray[offset + 2] = char.width * u; // texExtent.x + charsArray[offset + 3] = char.height * v; // texExtent.y + charsArray[offset + 4] = char.width; // size.x + charsArray[offset + 5] = char.height; // size.y + charsArray[offset + 6] = char.xoffset; // offset.x + charsArray[offset + 7] = -char.yoffset; // offset.y + offset += 8; + } + + charsBuffer.unmap(); + + const pageTextures = await Promise.all(pagePromises); + + const bindGroup = this.device.createBindGroup({ + label: 'msdf font bind group', + layout: this.fontBindGroupLayout, + entries: [ + { + binding: 0, + // TODO: Allow multi-page fonts + resource: pageTextures[0].createView(), + }, + { + binding: 1, + resource: this.sampler, + }, + { + binding: 2, + resource: { buffer: charsBuffer }, + }, + ], + }); + + const kernings = new Map(); + + if (json.kernings) { + for (const kearning of json.kernings) { + let charKerning = kernings.get(kearning.first); + if (!charKerning) { + charKerning = new Map(); + kernings.set(kearning.first, charKerning); + } + charKerning.set(kearning.second, kearning.amount); + } + } + + return new MsdfFont( + await this.pipelinePromise, + bindGroup, + json.common.lineHeight, + chars, + kernings + ); + } + + formatText( + font: MsdfFont, + text: string, + options: MsdfTextFormattingOptions = {} + ): MsdfText { + const textBuffer = this.device.createBuffer({ + label: 'msdf text buffer', + size: (text.length + 6) * Float32Array.BYTES_PER_ELEMENT * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + mappedAtCreation: true, + }); + + const textArray = new Float32Array(textBuffer.getMappedRange()); + let offset = 24; // Accounts for the values managed by MsdfText internally. + + let measurements: MsdfTextMeasurements; + if (options.centered) { + measurements = this.measureText(font, text); + + this.measureText( + font, + text, + (textX: number, textY: number, line: number, char: MsdfChar) => { + const lineOffset = + measurements.width * -0.5 - + (measurements.width - measurements.lineWidths[line]) * -0.5; + + textArray[offset] = textX + lineOffset; + textArray[offset + 1] = textY + measurements.height * 0.5; + textArray[offset + 2] = char.charIndex; + offset += 4; + } + ); + } else { + measurements = this.measureText( + font, + text, + (textX: number, textY: number, line: number, char: MsdfChar) => { + textArray[offset] = textX; + textArray[offset + 1] = textY; + textArray[offset + 2] = char.charIndex; + offset += 4; + } + ); + } + + textBuffer.unmap(); + + const bindGroup = this.device.createBindGroup({ + label: 'msdf text bind group', + layout: this.textBindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.cameraUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: textBuffer }, + }, + ], + }); + + const encoder = this.device.createRenderBundleEncoder( + this.renderBundleDescriptor + ); + encoder.setPipeline(font.pipeline); + encoder.setBindGroup(0, font.bindGroup); + encoder.setBindGroup(1, bindGroup); + encoder.draw(4, measurements.printedCharCount); + const renderBundle = encoder.finish(); + + const msdfText = new MsdfText( + this.device, + renderBundle, + measurements, + font, + textBuffer + ); + if (options.pixelScale !== undefined) { + msdfText.setPixelScale(options.pixelScale); + } + + if (options.color !== undefined) { + msdfText.setColor(...options.color); + } + + return msdfText; + } + + measureText( + font: MsdfFont, + text: string, + charCallback?: (x: number, y: number, line: number, char: MsdfChar) => void + ): MsdfTextMeasurements { + let maxWidth = 0; + const lineWidths: number[] = []; + + let textOffsetX = 0; + let textOffsetY = 0; + let line = 0; + let printedCharCount = 0; + let nextCharCode = text.charCodeAt(0); + for (let i = 0; i < text.length; ++i) { + const charCode = nextCharCode; + nextCharCode = i < text.length - 1 ? text.charCodeAt(i + 1) : -1; + + switch (charCode) { + case 10: // Newline + lineWidths.push(textOffsetX); + line++; + maxWidth = Math.max(maxWidth, textOffsetX); + textOffsetX = 0; + textOffsetY -= font.lineHeight; + case 13: // CR + break; + case 32: // Space + // For spaces, advance the offset without actually adding a character. + textOffsetX += font.getXAdvance(charCode); + break; + default: { + if (charCallback) { + charCallback( + textOffsetX, + textOffsetY, + line, + font.getChar(charCode) + ); + } + textOffsetX += font.getXAdvance(charCode, nextCharCode); + printedCharCount++; + } + } + } + + lineWidths.push(textOffsetX); + maxWidth = Math.max(maxWidth, textOffsetX); + + return { + width: maxWidth, + height: lineWidths.length * font.lineHeight, + lineWidths, + printedCharCount, + }; + } + + updateCamera(projection: Mat4, view: Mat4) { + this.cameraArray.set(projection, 0); + this.cameraArray.set(view, 16); + this.device.queue.writeBuffer( + this.cameraUniformBuffer, + 0, + this.cameraArray + ); + } + + render(renderPass: GPURenderPassEncoder, ...text: MsdfText[]) { + const renderBundles = text.map((t) => t.getRenderBundle()); + renderPass.executeBundles(renderBundles); + } +} diff --git a/sample/textRenderingMsdf/msdfText.wgsl b/sample/textRenderingMsdf/msdfText.wgsl new file mode 100644 index 00000000..87dfeb36 --- /dev/null +++ b/sample/textRenderingMsdf/msdfText.wgsl @@ -0,0 +1,79 @@ +// Positions for simple quad geometry +const pos = array(vec2f(0, -1), vec2f(1, -1), vec2f(0, 0), vec2f(1, 0)); + +struct VertexInput { + @builtin(vertex_index) vertex : u32, + @builtin(instance_index) instance : u32, +}; + +struct VertexOutput { + @builtin(position) position : vec4f, + @location(0) texcoord : vec2f, +}; + +struct Char { + texOffset: vec2f, + texExtent: vec2f, + size: vec2f, + offset: vec2f, +}; + +struct FormattedText { + transform: mat4x4f, + color: vec4f, + scale: f32, + chars: array, +}; + +struct Camera { + projection: mat4x4f, + view: mat4x4f, +}; + +// Font bindings +@group(0) @binding(0) var fontTexture: texture_2d; +@group(0) @binding(1) var fontSampler: sampler; +@group(0) @binding(2) var chars: array; + +// Text bindings +@group(1) @binding(0) var camera: Camera; +@group(1) @binding(1) var text: FormattedText; + +@vertex +fn vertexMain(input : VertexInput) -> VertexOutput { + let textElement = text.chars[input.instance]; + let char = chars[u32(textElement.z)]; + let charPos = (pos[input.vertex] * char.size + textElement.xy + char.offset) * text.scale; + + var output : VertexOutput; + output.position = camera.projection * camera.view * text.transform * vec4f(charPos, 0, 1); + + output.texcoord = pos[input.vertex] * vec2f(1, -1); + output.texcoord *= char.texExtent; + output.texcoord += char.texOffset; + return output; +} + +fn sampleMsdf(texcoord: vec2f) -> f32 { + let c = textureSample(fontTexture, fontSampler, texcoord); + return max(min(c.r, c.g), min(max(c.r, c.g), c.b)); +} + +// Antialiasing technique from https://drewcassidy.me/2020/06/26/sdf-antialiasing/ +@fragment +fn fragmentMain(input : VertexOutput) -> @location(0) vec4f { + let dist = 0.5 - sampleMsdf(input.texcoord); + + // sdf distance per pixel (gradient vector) + let ddist = vec2f(dpdx(dist), dpdy(dist)); + + // distance to edge in pixels (scalar) + let pixelDist = dist / length(ddist); + + let alpha = saturate(0.5 - pixelDist); + if (alpha < 0.001) { + discard; + } + + return vec4f(text.color.rgb, text.color.a * alpha); +} \ No newline at end of file diff --git a/src/samples.ts b/src/samples.ts index 22dd81ff..6d247d63 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -27,6 +27,7 @@ import samplerParameters from '../sample/samplerParameters/meta'; import shadowMapping from '../sample/shadowMapping/meta'; import skinnedMesh from '../sample/skinnedMesh/meta'; import spookyball from '../sample/spookyball/meta'; +import textRenderingMsdf from '../sample/textRenderingMsdf/meta'; import texturedCube from '../sample/texturedCube/meta'; import twoCubes from '../sample/twoCubes/meta'; import videoUploading from '../sample/videoUploading/meta'; @@ -115,6 +116,7 @@ export const pageCategories: PageCategory[] = [ cornell, 'a-buffer': aBuffer, skinnedMesh, + textRenderingMsdf, }, },