├── README.md └── example ├── bidi.mjs ├── common.css ├── index.html ├── troika-three-text.esm.js ├── troika-three-utils.esm.js ├── troika-worker-utils.esm.js ├── webgl-sdf-generator.mjs └── webxr-button.js /README.md: -------------------------------------------------------------------------------- 1 | # shared experiences made easy 2 | 3 | ## Introduction 4 | As headsets develop more powerful AR features, users want to be able to interact with other users in the same environment. 5 | This requires a common coordinate system and this is difficult to achieve. 6 | 7 | Developers would have to ask users to somehow agree on an origin by standing in a certain spot or touching a common point. These actions tend to be confusing for users, error prone and very imprecise. 8 | WebXR offers image tracking on certain platforms but that is difficult to set up and also suffers from being error prone. 9 | 10 | These drawbacks made it so shared immersive experiences are not commmon. 11 | 12 | ## Introducing shared spaces 13 | The Quest browser added an experimental feature named "shared spaces". 14 | This new feature gives the developer the ability to automatically create a shared coordinate space among headsets in the same room. These headsets will also share a unique ID which they can use to set up a group. 15 | The feature is available as an experiment in v39 of the Quest browser. To enable it, go to chrome://flags, search for "WebXR experiments", enable it and restart the browser. 16 | 17 | To use it, the developer adds the "shared" feature to the []`requestSession`](https://immersive-web.github.io/webxr/#dom-xrsystem-requestsession) call and once the session is running, they can ask for a "shared" reference space. This space has a unique ID and is shared among the headsets in the same area. With this information, the developer should be able to create a shared experience since coordinates between the headset can be freely interchanged. 18 | 19 | ## Properties of shared spaces 20 | - Each shared space is bound to size of a room. Headsets that are further away will not participate but they may start to participate if they get closer. 21 | - A shared space is only exposed to the particular site. For instance, 'bar.com/a.html' will not be able to see the shared space of 'bar.com/b.html'. They will each get a unique space and uuid. 22 | - When the WebXR session starts, it may take a couple of seconds to establish the correct shared space. Until then, the browser will report a default shared space. After the correct one is established, the `reset` event will be called on the shared space and a new coordinate system and UUID will be established. If the headset was first to go immersive, no reset event is generated. 23 | - Participants may enter and leave at will. They will always be able to establish a common coordiante system when restarting the WebXR session. (By design, the origin of the first headset that created a shared space will be origin of the common coordinate space). 24 | - When a partipant exists WebXR, the shared spade is lost and will need to be recreated when reentering WebXR. 25 | - Headsets may come and go freely from the shared space, but once the last one leaves, the shared space is lost. It may be possible to recover it but we need more developer feedback on a good API shape for this. 26 | 27 | ## Additions to the WebXR spec 28 | - Add `shared` to list of reference space types in https://immersive-web.github.io/webxr/#enumdef-xrreferencespacetype 29 | - Create a new object `XRSharedReferencSpace` in https://immersive-web.github.io/webxr/#spaces. This object has a UUID string and is created with the usual [requestReferenceSpace](https://immersive-web.github.io/webxr/#dom-xrsession-requestreferencespace) API. 30 | 31 | ## Sample site 32 | This git repo also contains a basic example of a simple game that uses a shared space. It's hosted [here](https://sharedshooter.arvr.social/). 33 | 34 | Once a user goes immersive, they will see other joined users and will be able to "shoot" at them using their controllers. The game will keep track of the number of succesful hits and display it above each user. 35 | 36 | Here's a small recording of me and my daughter playing the game: 37 | [![shared space demo](https://img.youtube.com/vi/Eap8upxWEcw/maxresdefault.jpg)](https://www.youtube.com/shorts/Eap8upxWEcw) 38 | 39 | Quick facts: 40 | - the experience uses [peerjs](https://peerjs.com/) to communicate between headsets. 41 | - the experience will send the shared space UUID and the peerjs UUID to a server and it will return a list of other headsets with that same shared space UUID. This list is then used to connect to the other sets. 42 | - every 5 seconds, the location of the headset and the controllers are sent to the other participants. 43 | - hit testing of the bullits is done on the headset that does the "shooting". Score is also kept on the server and sent down to all participants. 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /example/bidi.mjs: -------------------------------------------------------------------------------- 1 | function bidiFactory() { 2 | var bidi = (function (exports) { 3 | 4 | // Bidi character types data, auto generated 5 | var DATA = { 6 | "R": "13k,1a,2,3,3,2+1j,ch+16,a+1,5+2,2+n,5,a,4,6+16,4+3,h+1b,4mo,179q,2+9,2+11,2i9+7y,2+68,4,3+4,5+13,4+3,2+4k,3+29,8+cf,1t+7z,w+17,3+3m,1t+3z,16o1+5r,8+30,8+mc,29+1r,29+4v,75+73", 7 | "EN": "1c+9,3d+1,6,187+9,513,4+5,7+9,sf+j,175h+9,qw+q,161f+1d,4xt+a,25i+9", 8 | "ES": "17,2,6dp+1,f+1,av,16vr,mx+1,4o,2", 9 | "ET": "z+2,3h+3,b+1,ym,3e+1,2o,p4+1,8,6u,7c,g6,1wc,1n9+4,30+1b,2n,6d,qhx+1,h0m,a+1,49+2,63+1,4+1,6bb+3,12jj", 10 | "AN": "16o+5,2j+9,2+1,35,ed,1ff2+9,87+u", 11 | "CS": "18,2+1,b,2u,12k,55v,l,17v0,2,3,53,2+1,b", 12 | "B": "a,3,f+2,2v,690", 13 | "S": "9,2,k", 14 | "WS": "c,k,4f4,1vk+a,u,1j,335", 15 | "ON": "x+1,4+4,h+5,r+5,r+3,z,5+3,2+1,2+1,5,2+2,3+4,o,w,ci+1,8+d,3+d,6+8,2+g,39+1,9,6+1,2,33,b8,3+1,3c+1,7+1,5r,b,7h+3,sa+5,2,3i+6,jg+3,ur+9,2v,ij+1,9g+9,7+a,8m,4+1,49+x,14u,2+2,c+2,e+2,e+2,e+1,i+n,e+e,2+p,u+2,e+2,36+1,2+3,2+1,b,2+2,6+5,2,2,2,h+1,5+4,6+3,3+f,16+2,5+3l,3+81,1y+p,2+40,q+a,m+13,2r+ch,2+9e,75+hf,3+v,2+2w,6e+5,f+6,75+2a,1a+p,2+2g,d+5x,r+b,6+3,4+o,g,6+1,6+2,2k+1,4,2j,5h+z,1m+1,1e+f,t+2,1f+e,d+3,4o+3,2s+1,w,535+1r,h3l+1i,93+2,2s,b+1,3l+x,2v,4g+3,21+3,kz+1,g5v+1,5a,j+9,n+v,2,3,2+8,2+1,3+2,2,3,46+1,4+4,h+5,r+5,r+a,3h+2,4+6,b+4,78,1r+24,4+c,4,1hb,ey+6,103+j,16j+c,1ux+7,5+g,fsh,jdq+1t,4,57+2e,p1,1m,1m,1m,1m,4kt+1,7j+17,5+2r,d+e,3+e,2+e,2+10,m+4,w,1n+5,1q,4z+5,4b+rb,9+c,4+c,4+37,d+2g,8+b,l+b,5+1j,9+9,7+13,9+t,3+1,27+3c,2+29,2+3q,d+d,3+4,4+2,6+6,a+o,8+6,a+2,e+6,16+42,2+1i", 16 | "BN": "0+8,6+d,2s+5,2+p,e,4m9,1kt+2,2b+5,5+5,17q9+v,7k,6p+8,6+1,119d+3,440+7,96s+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+75,6p+2rz,1ben+1,1ekf+1,1ekf+1", 17 | "NSM": "lc+33,7o+6,7c+18,2,2+1,2+1,2,21+a,1d+k,h,2u+6,3+5,3+1,2+3,10,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,g+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+g,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,k1+w,2db+2,3y,2p+v,ff+3,30+1,n9x+3,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,r2,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+5,3+1,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2d+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,f0c+4,1o+6,t5,1s+3,2a,f5l+1,43t+2,i+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,gzhy+6n", 18 | "AL": "16w,3,2,e+1b,z+2,2+2s,g+1,8+1,b+m,2+t,s+2i,c+e,4h+f,1d+1e,1bwe+dp,3+3z,x+c,2+1,35+3y,2rm+z,5+7,b+5,dt+l,c+u,17nl+27,1t+27,4x+6n,3+d", 19 | "LRO": "6ct", 20 | "RLO": "6cu", 21 | "LRE": "6cq", 22 | "RLE": "6cr", 23 | "PDF": "6cs", 24 | "LRI": "6ee", 25 | "RLI": "6ef", 26 | "FSI": "6eg", 27 | "PDI": "6eh" 28 | }; 29 | 30 | var TYPES = {}; 31 | var TYPES_TO_NAMES = {}; 32 | TYPES.L = 1; //L is the default 33 | TYPES_TO_NAMES[1] = 'L'; 34 | Object.keys(DATA).forEach(function (type, i) { 35 | TYPES[type] = 1 << (i + 1); 36 | TYPES_TO_NAMES[TYPES[type]] = type; 37 | }); 38 | Object.freeze(TYPES); 39 | 40 | var ISOLATE_INIT_TYPES = TYPES.LRI | TYPES.RLI | TYPES.FSI; 41 | var STRONG_TYPES = TYPES.L | TYPES.R | TYPES.AL; 42 | var NEUTRAL_ISOLATE_TYPES = TYPES.B | TYPES.S | TYPES.WS | TYPES.ON | TYPES.FSI | TYPES.LRI | TYPES.RLI | TYPES.PDI; 43 | var BN_LIKE_TYPES = TYPES.BN | TYPES.RLE | TYPES.LRE | TYPES.RLO | TYPES.LRO | TYPES.PDF; 44 | var TRAILING_TYPES = TYPES.S | TYPES.WS | TYPES.B | ISOLATE_INIT_TYPES | TYPES.PDI | BN_LIKE_TYPES; 45 | 46 | var map = null; 47 | 48 | function parseData () { 49 | if (!map) { 50 | //const start = performance.now() 51 | map = new Map(); 52 | var loop = function ( type ) { 53 | if (DATA.hasOwnProperty(type)) { 54 | var lastCode = 0; 55 | DATA[type].split(',').forEach(function (range) { 56 | var ref = range.split('+'); 57 | var skip = ref[0]; 58 | var step = ref[1]; 59 | skip = parseInt(skip, 36); 60 | step = step ? parseInt(step, 36) : 0; 61 | map.set(lastCode += skip, TYPES[type]); 62 | for (var i = 0; i < step; i++) { 63 | map.set(++lastCode, TYPES[type]); 64 | } 65 | }); 66 | } 67 | }; 68 | 69 | for (var type in DATA) loop( type ); 70 | //console.log(`char types parsed in ${performance.now() - start}ms`) 71 | } 72 | } 73 | 74 | /** 75 | * @param {string} char 76 | * @return {number} 77 | */ 78 | function getBidiCharType (char) { 79 | parseData(); 80 | return map.get(char.codePointAt(0)) || TYPES.L 81 | } 82 | 83 | function getBidiCharTypeName(char) { 84 | return TYPES_TO_NAMES[getBidiCharType(char)] 85 | } 86 | 87 | // Bidi bracket pairs data, auto generated 88 | var data$1 = { 89 | "pairs": "14>1,1e>2,u>2,2wt>1,1>1,1ge>1,1wp>1,1j>1,f>1,hm>1,1>1,u>1,u6>1,1>1,+5,28>1,w>1,1>1,+3,b8>1,1>1,+3,1>3,-1>-1,3>1,1>1,+2,1s>1,1>1,x>1,th>1,1>1,+2,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,4q>1,1e>2,u>2,2>1,+1", 90 | "canonical": "6f1>-6dx,6dy>-6dx,6ec>-6ed,6ee>-6ed,6ww>2jj,-2ji>2jj,14r4>-1e7l,1e7m>-1e7l,1e7m>-1e5c,1e5d>-1e5b,1e5c>-14qx,14qy>-14qx,14vn>-1ecg,1ech>-1ecg,1edu>-1ecg,1eci>-1ecg,1eda>-1ecg,1eci>-1ecg,1eci>-168q,168r>-168q,168s>-14ye,14yf>-14ye" 91 | }; 92 | 93 | /** 94 | * Parses an string that holds encoded codepoint mappings, e.g. for bracket pairs or 95 | * mirroring characters, as encoded by scripts/generateBidiData.js. Returns an object 96 | * holding the `map`, and optionally a `reverseMap` if `includeReverse:true`. 97 | * @param {string} encodedString 98 | * @param {boolean} includeReverse - true if you want reverseMap in the output 99 | * @return {{map: Map, reverseMap?: Map}} 100 | */ 101 | function parseCharacterMap (encodedString, includeReverse) { 102 | var radix = 36; 103 | var lastCode = 0; 104 | var map = new Map(); 105 | var reverseMap = includeReverse && new Map(); 106 | var prevPair; 107 | encodedString.split(',').forEach(function visit(entry) { 108 | if (entry.indexOf('+') !== -1) { 109 | for (var i = +entry; i--;) { 110 | visit(prevPair); 111 | } 112 | } else { 113 | prevPair = entry; 114 | var ref = entry.split('>'); 115 | var a = ref[0]; 116 | var b = ref[1]; 117 | a = String.fromCodePoint(lastCode += parseInt(a, radix)); 118 | b = String.fromCodePoint(lastCode += parseInt(b, radix)); 119 | map.set(a, b); 120 | includeReverse && reverseMap.set(b, a); 121 | } 122 | }); 123 | return { map: map, reverseMap: reverseMap } 124 | } 125 | 126 | var openToClose, closeToOpen, canonical; 127 | 128 | function parse$1 () { 129 | if (!openToClose) { 130 | //const start = performance.now() 131 | var ref = parseCharacterMap(data$1.pairs, true); 132 | var map = ref.map; 133 | var reverseMap = ref.reverseMap; 134 | openToClose = map; 135 | closeToOpen = reverseMap; 136 | canonical = parseCharacterMap(data$1.canonical, false).map; 137 | //console.log(`brackets parsed in ${performance.now() - start}ms`) 138 | } 139 | } 140 | 141 | function openingToClosingBracket (char) { 142 | parse$1(); 143 | return openToClose.get(char) || null 144 | } 145 | 146 | function closingToOpeningBracket (char) { 147 | parse$1(); 148 | return closeToOpen.get(char) || null 149 | } 150 | 151 | function getCanonicalBracket (char) { 152 | parse$1(); 153 | return canonical.get(char) || null 154 | } 155 | 156 | // Local type aliases 157 | var TYPE_L = TYPES.L; 158 | var TYPE_R = TYPES.R; 159 | var TYPE_EN = TYPES.EN; 160 | var TYPE_ES = TYPES.ES; 161 | var TYPE_ET = TYPES.ET; 162 | var TYPE_AN = TYPES.AN; 163 | var TYPE_CS = TYPES.CS; 164 | var TYPE_B = TYPES.B; 165 | var TYPE_S = TYPES.S; 166 | var TYPE_ON = TYPES.ON; 167 | var TYPE_BN = TYPES.BN; 168 | var TYPE_NSM = TYPES.NSM; 169 | var TYPE_AL = TYPES.AL; 170 | var TYPE_LRO = TYPES.LRO; 171 | var TYPE_RLO = TYPES.RLO; 172 | var TYPE_LRE = TYPES.LRE; 173 | var TYPE_RLE = TYPES.RLE; 174 | var TYPE_PDF = TYPES.PDF; 175 | var TYPE_LRI = TYPES.LRI; 176 | var TYPE_RLI = TYPES.RLI; 177 | var TYPE_FSI = TYPES.FSI; 178 | var TYPE_PDI = TYPES.PDI; 179 | 180 | /** 181 | * @typedef {object} GetEmbeddingLevelsResult 182 | * @property {{start, end, level}[]} paragraphs 183 | * @property {Uint8Array} levels 184 | */ 185 | 186 | /** 187 | * This function applies the Bidirectional Algorithm to a string, returning the resolved embedding levels 188 | * in a single Uint8Array plus a list of objects holding each paragraph's start and end indices and resolved 189 | * base embedding level. 190 | * 191 | * @param {string} string - The input string 192 | * @param {"ltr"|"rtl"|"auto"} [baseDirection] - Use "ltr" or "rtl" to force a base paragraph direction, 193 | * otherwise a direction will be chosen automatically from each paragraph's contents. 194 | * @return {GetEmbeddingLevelsResult} 195 | */ 196 | function getEmbeddingLevels (string, baseDirection) { 197 | var MAX_DEPTH = 125; 198 | 199 | // Start by mapping all characters to their unicode type, as a bitmask integer 200 | var charTypes = new Uint32Array(string.length); 201 | for (var i = 0; i < string.length; i++) { 202 | charTypes[i] = getBidiCharType(string[i]); 203 | } 204 | 205 | var charTypeCounts = new Map(); //will be cleared at start of each paragraph 206 | function changeCharType(i, type) { 207 | var oldType = charTypes[i]; 208 | charTypes[i] = type; 209 | charTypeCounts.set(oldType, charTypeCounts.get(oldType) - 1); 210 | if (oldType & NEUTRAL_ISOLATE_TYPES) { 211 | charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) - 1); 212 | } 213 | charTypeCounts.set(type, (charTypeCounts.get(type) || 0) + 1); 214 | if (type & NEUTRAL_ISOLATE_TYPES) { 215 | charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1); 216 | } 217 | } 218 | 219 | var embedLevels = new Uint8Array(string.length); 220 | var isolationPairs = new Map(); //init->pdi and pdi->init 221 | 222 | // === 3.3.1 The Paragraph Level === 223 | // 3.3.1 P1: Split the text into paragraphs 224 | var paragraphs = []; // [{start, end, level}, ...] 225 | var paragraph = null; 226 | for (var i$1 = 0; i$1 < string.length; i$1++) { 227 | if (!paragraph) { 228 | paragraphs.push(paragraph = { 229 | start: i$1, 230 | end: string.length - 1, 231 | // 3.3.1 P2-P3: Determine the paragraph level 232 | level: baseDirection === 'rtl' ? 1 : baseDirection === 'ltr' ? 0 : determineAutoEmbedLevel(i$1, false) 233 | }); 234 | } 235 | if (charTypes[i$1] & TYPE_B) { 236 | paragraph.end = i$1; 237 | paragraph = null; 238 | } 239 | } 240 | 241 | var FORMATTING_TYPES = TYPE_RLE | TYPE_LRE | TYPE_RLO | TYPE_LRO | ISOLATE_INIT_TYPES | TYPE_PDI | TYPE_PDF | TYPE_B; 242 | var nextEven = function (n) { return n + ((n & 1) ? 1 : 2); }; 243 | var nextOdd = function (n) { return n + ((n & 1) ? 2 : 1); }; 244 | 245 | // Everything from here on will operate per paragraph. 246 | for (var paraIdx = 0; paraIdx < paragraphs.length; paraIdx++) { 247 | paragraph = paragraphs[paraIdx]; 248 | var statusStack = [{ 249 | _level: paragraph.level, 250 | _override: 0, //0=neutral, 1=L, 2=R 251 | _isolate: 0 //bool 252 | }]; 253 | var stackTop = (void 0); 254 | var overflowIsolateCount = 0; 255 | var overflowEmbeddingCount = 0; 256 | var validIsolateCount = 0; 257 | charTypeCounts.clear(); 258 | 259 | // === 3.3.2 Explicit Levels and Directions === 260 | for (var i$2 = paragraph.start; i$2 <= paragraph.end; i$2++) { 261 | var charType = charTypes[i$2]; 262 | stackTop = statusStack[statusStack.length - 1]; 263 | 264 | // Set initial counts 265 | charTypeCounts.set(charType, (charTypeCounts.get(charType) || 0) + 1); 266 | if (charType & NEUTRAL_ISOLATE_TYPES) { 267 | charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1); 268 | } 269 | 270 | // Explicit Embeddings: 3.3.2 X2 - X3 271 | if (charType & FORMATTING_TYPES) { //prefilter all formatters 272 | if (charType & (TYPE_RLE | TYPE_LRE)) { 273 | embedLevels[i$2] = stackTop._level; // 5.2 274 | var level = (charType === TYPE_RLE ? nextOdd : nextEven)(stackTop._level); 275 | if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) { 276 | statusStack.push({ 277 | _level: level, 278 | _override: 0, 279 | _isolate: 0 280 | }); 281 | } else if (!overflowIsolateCount) { 282 | overflowEmbeddingCount++; 283 | } 284 | } 285 | 286 | // Explicit Overrides: 3.3.2 X4 - X5 287 | else if (charType & (TYPE_RLO | TYPE_LRO)) { 288 | embedLevels[i$2] = stackTop._level; // 5.2 289 | var level$1 = (charType === TYPE_RLO ? nextOdd : nextEven)(stackTop._level); 290 | if (level$1 <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) { 291 | statusStack.push({ 292 | _level: level$1, 293 | _override: (charType & TYPE_RLO) ? TYPE_R : TYPE_L, 294 | _isolate: 0 295 | }); 296 | } else if (!overflowIsolateCount) { 297 | overflowEmbeddingCount++; 298 | } 299 | } 300 | 301 | // Isolates: 3.3.2 X5a - X5c 302 | else if (charType & ISOLATE_INIT_TYPES) { 303 | // X5c - FSI becomes either RLI or LRI 304 | if (charType & TYPE_FSI) { 305 | charType = determineAutoEmbedLevel(i$2 + 1, true) === 1 ? TYPE_RLI : TYPE_LRI; 306 | } 307 | 308 | embedLevels[i$2] = stackTop._level; 309 | if (stackTop._override) { 310 | changeCharType(i$2, stackTop._override); 311 | } 312 | var level$2 = (charType === TYPE_RLI ? nextOdd : nextEven)(stackTop._level); 313 | if (level$2 <= MAX_DEPTH && overflowIsolateCount === 0 && overflowEmbeddingCount === 0) { 314 | validIsolateCount++; 315 | statusStack.push({ 316 | _level: level$2, 317 | _override: 0, 318 | _isolate: 1, 319 | _isolInitIndex: i$2 320 | }); 321 | } else { 322 | overflowIsolateCount++; 323 | } 324 | } 325 | 326 | // Terminating Isolates: 3.3.2 X6a 327 | else if (charType & TYPE_PDI) { 328 | if (overflowIsolateCount > 0) { 329 | overflowIsolateCount--; 330 | } else if (validIsolateCount > 0) { 331 | overflowEmbeddingCount = 0; 332 | while (!statusStack[statusStack.length - 1]._isolate) { 333 | statusStack.pop(); 334 | } 335 | // Add to isolation pairs bidirectional mapping: 336 | var isolInitIndex = statusStack[statusStack.length - 1]._isolInitIndex; 337 | if (isolInitIndex != null) { 338 | isolationPairs.set(isolInitIndex, i$2); 339 | isolationPairs.set(i$2, isolInitIndex); 340 | } 341 | statusStack.pop(); 342 | validIsolateCount--; 343 | } 344 | stackTop = statusStack[statusStack.length - 1]; 345 | embedLevels[i$2] = stackTop._level; 346 | if (stackTop._override) { 347 | changeCharType(i$2, stackTop._override); 348 | } 349 | } 350 | 351 | 352 | // Terminating Embeddings and Overrides: 3.3.2 X7 353 | else if (charType & TYPE_PDF) { 354 | if (overflowIsolateCount === 0) { 355 | if (overflowEmbeddingCount > 0) { 356 | overflowEmbeddingCount--; 357 | } else if (!stackTop._isolate && statusStack.length > 1) { 358 | statusStack.pop(); 359 | stackTop = statusStack[statusStack.length - 1]; 360 | } 361 | } 362 | embedLevels[i$2] = stackTop._level; // 5.2 363 | } 364 | 365 | // End of Paragraph: 3.3.2 X8 366 | else if (charType & TYPE_B) { 367 | embedLevels[i$2] = paragraph.level; 368 | } 369 | } 370 | 371 | // Non-formatting characters: 3.3.2 X6 372 | else { 373 | embedLevels[i$2] = stackTop._level; 374 | // NOTE: This exclusion of BN seems to go against what section 5.2 says, but is required for test passage 375 | if (stackTop._override && charType !== TYPE_BN) { 376 | changeCharType(i$2, stackTop._override); 377 | } 378 | } 379 | } 380 | 381 | // === 3.3.3 Preparations for Implicit Processing === 382 | 383 | // Remove all RLE, LRE, RLO, LRO, PDF, and BN characters: 3.3.3 X9 384 | // Note: Due to section 5.2, we won't remove them, but we'll use the BN_LIKE_TYPES bitset to 385 | // easily ignore them all from here on out. 386 | 387 | // 3.3.3 X10 388 | // Compute the set of isolating run sequences as specified by BD13 389 | var levelRuns = []; 390 | var currentRun = null; 391 | for (var i$3 = paragraph.start; i$3 <= paragraph.end; i$3++) { 392 | var charType$1 = charTypes[i$3]; 393 | if (!(charType$1 & BN_LIKE_TYPES)) { 394 | var lvl = embedLevels[i$3]; 395 | var isIsolInit = charType$1 & ISOLATE_INIT_TYPES; 396 | var isPDI = charType$1 === TYPE_PDI; 397 | if (currentRun && lvl === currentRun._level) { 398 | currentRun._end = i$3; 399 | currentRun._endsWithIsolInit = isIsolInit; 400 | } else { 401 | levelRuns.push(currentRun = { 402 | _start: i$3, 403 | _end: i$3, 404 | _level: lvl, 405 | _startsWithPDI: isPDI, 406 | _endsWithIsolInit: isIsolInit 407 | }); 408 | } 409 | } 410 | } 411 | var isolatingRunSeqs = []; // [{seqIndices: [], sosType: L|R, eosType: L|R}] 412 | for (var runIdx = 0; runIdx < levelRuns.length; runIdx++) { 413 | var run = levelRuns[runIdx]; 414 | if (!run._startsWithPDI || (run._startsWithPDI && !isolationPairs.has(run._start))) { 415 | var seqRuns = [currentRun = run]; 416 | for (var pdiIndex = (void 0); currentRun && currentRun._endsWithIsolInit && (pdiIndex = isolationPairs.get(currentRun._end)) != null;) { 417 | for (var i$4 = runIdx + 1; i$4 < levelRuns.length; i$4++) { 418 | if (levelRuns[i$4]._start === pdiIndex) { 419 | seqRuns.push(currentRun = levelRuns[i$4]); 420 | break 421 | } 422 | } 423 | } 424 | // build flat list of indices across all runs: 425 | var seqIndices = []; 426 | for (var i$5 = 0; i$5 < seqRuns.length; i$5++) { 427 | var run$1 = seqRuns[i$5]; 428 | for (var j = run$1._start; j <= run$1._end; j++) { 429 | seqIndices.push(j); 430 | } 431 | } 432 | // determine the sos/eos types: 433 | var firstLevel = embedLevels[seqIndices[0]]; 434 | var prevLevel = paragraph.level; 435 | for (var i$6 = seqIndices[0] - 1; i$6 >= 0; i$6--) { 436 | if (!(charTypes[i$6] & BN_LIKE_TYPES)) { //5.2 437 | prevLevel = embedLevels[i$6]; 438 | break 439 | } 440 | } 441 | var lastIndex = seqIndices[seqIndices.length - 1]; 442 | var lastLevel = embedLevels[lastIndex]; 443 | var nextLevel = paragraph.level; 444 | if (!(charTypes[lastIndex] & ISOLATE_INIT_TYPES)) { 445 | for (var i$7 = lastIndex + 1; i$7 <= paragraph.end; i$7++) { 446 | if (!(charTypes[i$7] & BN_LIKE_TYPES)) { //5.2 447 | nextLevel = embedLevels[i$7]; 448 | break 449 | } 450 | } 451 | } 452 | isolatingRunSeqs.push({ 453 | _seqIndices: seqIndices, 454 | _sosType: Math.max(prevLevel, firstLevel) % 2 ? TYPE_R : TYPE_L, 455 | _eosType: Math.max(nextLevel, lastLevel) % 2 ? TYPE_R : TYPE_L 456 | }); 457 | } 458 | } 459 | 460 | // The next steps are done per isolating run sequence 461 | for (var seqIdx = 0; seqIdx < isolatingRunSeqs.length; seqIdx++) { 462 | var ref = isolatingRunSeqs[seqIdx]; 463 | var seqIndices$1 = ref._seqIndices; 464 | var sosType = ref._sosType; 465 | var eosType = ref._eosType; 466 | /** 467 | * All the level runs in an isolating run sequence have the same embedding level. 468 | * 469 | * DO NOT change any `embedLevels[i]` within the current scope. 470 | */ 471 | var embedDirection = ((embedLevels[seqIndices$1[0]]) & 1) ? TYPE_R : TYPE_L; 472 | 473 | // === 3.3.4 Resolving Weak Types === 474 | 475 | // W1 + 5.2. Search backward from each NSM to the first character in the isolating run sequence whose 476 | // bidirectional type is not BN, and set the NSM to ON if it is an isolate initiator or PDI, and to its 477 | // type otherwise. If the NSM is the first non-BN character, change the NSM to the type of sos. 478 | if (charTypeCounts.get(TYPE_NSM)) { 479 | for (var si = 0; si < seqIndices$1.length; si++) { 480 | var i$8 = seqIndices$1[si]; 481 | if (charTypes[i$8] & TYPE_NSM) { 482 | var prevType = sosType; 483 | for (var sj = si - 1; sj >= 0; sj--) { 484 | if (!(charTypes[seqIndices$1[sj]] & BN_LIKE_TYPES)) { //5.2 scan back to first non-BN 485 | prevType = charTypes[seqIndices$1[sj]]; 486 | break 487 | } 488 | } 489 | changeCharType(i$8, (prevType & (ISOLATE_INIT_TYPES | TYPE_PDI)) ? TYPE_ON : prevType); 490 | } 491 | } 492 | } 493 | 494 | // W2. Search backward from each instance of a European number until the first strong type (R, L, AL, or sos) 495 | // is found. If an AL is found, change the type of the European number to Arabic number. 496 | if (charTypeCounts.get(TYPE_EN)) { 497 | for (var si$1 = 0; si$1 < seqIndices$1.length; si$1++) { 498 | var i$9 = seqIndices$1[si$1]; 499 | if (charTypes[i$9] & TYPE_EN) { 500 | for (var sj$1 = si$1 - 1; sj$1 >= -1; sj$1--) { 501 | var prevCharType = sj$1 === -1 ? sosType : charTypes[seqIndices$1[sj$1]]; 502 | if (prevCharType & STRONG_TYPES) { 503 | if (prevCharType === TYPE_AL) { 504 | changeCharType(i$9, TYPE_AN); 505 | } 506 | break 507 | } 508 | } 509 | } 510 | } 511 | } 512 | 513 | // W3. Change all ALs to R 514 | if (charTypeCounts.get(TYPE_AL)) { 515 | for (var si$2 = 0; si$2 < seqIndices$1.length; si$2++) { 516 | var i$10 = seqIndices$1[si$2]; 517 | if (charTypes[i$10] & TYPE_AL) { 518 | changeCharType(i$10, TYPE_R); 519 | } 520 | } 521 | } 522 | 523 | // W4. A single European separator between two European numbers changes to a European number. A single common 524 | // separator between two numbers of the same type changes to that type. 525 | if (charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) { 526 | for (var si$3 = 1; si$3 < seqIndices$1.length - 1; si$3++) { 527 | var i$11 = seqIndices$1[si$3]; 528 | if (charTypes[i$11] & (TYPE_ES | TYPE_CS)) { 529 | var prevType$1 = 0, nextType = 0; 530 | for (var sj$2 = si$3 - 1; sj$2 >= 0; sj$2--) { 531 | prevType$1 = charTypes[seqIndices$1[sj$2]]; 532 | if (!(prevType$1 & BN_LIKE_TYPES)) { //5.2 533 | break 534 | } 535 | } 536 | for (var sj$3 = si$3 + 1; sj$3 < seqIndices$1.length; sj$3++) { 537 | nextType = charTypes[seqIndices$1[sj$3]]; 538 | if (!(nextType & BN_LIKE_TYPES)) { //5.2 539 | break 540 | } 541 | } 542 | if (prevType$1 === nextType && (charTypes[i$11] === TYPE_ES ? prevType$1 === TYPE_EN : (prevType$1 & (TYPE_EN | TYPE_AN)))) { 543 | changeCharType(i$11, prevType$1); 544 | } 545 | } 546 | } 547 | } 548 | 549 | // W5. A sequence of European terminators adjacent to European numbers changes to all European numbers. 550 | if (charTypeCounts.get(TYPE_EN)) { 551 | for (var si$4 = 0; si$4 < seqIndices$1.length; si$4++) { 552 | var i$12 = seqIndices$1[si$4]; 553 | if (charTypes[i$12] & TYPE_EN) { 554 | for (var sj$4 = si$4 - 1; sj$4 >= 0 && (charTypes[seqIndices$1[sj$4]] & (TYPE_ET | BN_LIKE_TYPES)); sj$4--) { 555 | changeCharType(seqIndices$1[sj$4], TYPE_EN); 556 | } 557 | for (si$4++; si$4 < seqIndices$1.length && (charTypes[seqIndices$1[si$4]] & (TYPE_ET | BN_LIKE_TYPES | TYPE_EN)); si$4++) { 558 | if (charTypes[seqIndices$1[si$4]] !== TYPE_EN) { 559 | changeCharType(seqIndices$1[si$4], TYPE_EN); 560 | } 561 | } 562 | } 563 | } 564 | } 565 | 566 | // W6. Otherwise, separators and terminators change to Other Neutral. 567 | if (charTypeCounts.get(TYPE_ET) || charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) { 568 | for (var si$5 = 0; si$5 < seqIndices$1.length; si$5++) { 569 | var i$13 = seqIndices$1[si$5]; 570 | if (charTypes[i$13] & (TYPE_ET | TYPE_ES | TYPE_CS)) { 571 | changeCharType(i$13, TYPE_ON); 572 | // 5.2 transform adjacent BNs too: 573 | for (var sj$5 = si$5 - 1; sj$5 >= 0 && (charTypes[seqIndices$1[sj$5]] & BN_LIKE_TYPES); sj$5--) { 574 | changeCharType(seqIndices$1[sj$5], TYPE_ON); 575 | } 576 | for (var sj$6 = si$5 + 1; sj$6 < seqIndices$1.length && (charTypes[seqIndices$1[sj$6]] & BN_LIKE_TYPES); sj$6++) { 577 | changeCharType(seqIndices$1[sj$6], TYPE_ON); 578 | } 579 | } 580 | } 581 | } 582 | 583 | // W7. Search backward from each instance of a European number until the first strong type (R, L, or sos) 584 | // is found. If an L is found, then change the type of the European number to L. 585 | // NOTE: implemented in single forward pass for efficiency 586 | if (charTypeCounts.get(TYPE_EN)) { 587 | for (var si$6 = 0, prevStrongType = sosType; si$6 < seqIndices$1.length; si$6++) { 588 | var i$14 = seqIndices$1[si$6]; 589 | var type = charTypes[i$14]; 590 | if (type & TYPE_EN) { 591 | if (prevStrongType === TYPE_L) { 592 | changeCharType(i$14, TYPE_L); 593 | } 594 | } else if (type & STRONG_TYPES) { 595 | prevStrongType = type; 596 | } 597 | } 598 | } 599 | 600 | // === 3.3.5 Resolving Neutral and Isolate Formatting Types === 601 | 602 | if (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES)) { 603 | // N0. Process bracket pairs in an isolating run sequence sequentially in the logical order of the text 604 | // positions of the opening paired brackets using the logic given below. Within this scope, bidirectional 605 | // types EN and AN are treated as R. 606 | var R_TYPES_FOR_N_STEPS = (TYPE_R | TYPE_EN | TYPE_AN); 607 | var STRONG_TYPES_FOR_N_STEPS = R_TYPES_FOR_N_STEPS | TYPE_L; 608 | 609 | // * Identify the bracket pairs in the current isolating run sequence according to BD16. 610 | var bracketPairs = []; 611 | { 612 | var openerStack = []; 613 | for (var si$7 = 0; si$7 < seqIndices$1.length; si$7++) { 614 | // NOTE: for any potential bracket character we also test that it still carries a NI 615 | // type, as that may have been changed earlier. This doesn't seem to be explicitly 616 | // called out in the spec, but is required for passage of certain tests. 617 | if (charTypes[seqIndices$1[si$7]] & NEUTRAL_ISOLATE_TYPES) { 618 | var char = string[seqIndices$1[si$7]]; 619 | var oppositeBracket = (void 0); 620 | // Opening bracket 621 | if (openingToClosingBracket(char) !== null) { 622 | if (openerStack.length < 63) { 623 | openerStack.push({ char: char, seqIndex: si$7 }); 624 | } else { 625 | break 626 | } 627 | } 628 | // Closing bracket 629 | else if ((oppositeBracket = closingToOpeningBracket(char)) !== null) { 630 | for (var stackIdx = openerStack.length - 1; stackIdx >= 0; stackIdx--) { 631 | var stackChar = openerStack[stackIdx].char; 632 | if (stackChar === oppositeBracket || 633 | stackChar === closingToOpeningBracket(getCanonicalBracket(char)) || 634 | openingToClosingBracket(getCanonicalBracket(stackChar)) === char 635 | ) { 636 | bracketPairs.push([openerStack[stackIdx].seqIndex, si$7]); 637 | openerStack.length = stackIdx; //pop the matching bracket and all following 638 | break 639 | } 640 | } 641 | } 642 | } 643 | } 644 | bracketPairs.sort(function (a, b) { return a[0] - b[0]; }); 645 | } 646 | // * For each bracket-pair element in the list of pairs of text positions 647 | for (var pairIdx = 0; pairIdx < bracketPairs.length; pairIdx++) { 648 | var ref$1 = bracketPairs[pairIdx]; 649 | var openSeqIdx = ref$1[0]; 650 | var closeSeqIdx = ref$1[1]; 651 | // a. Inspect the bidirectional types of the characters enclosed within the bracket pair. 652 | // b. If any strong type (either L or R) matching the embedding direction is found, set the type for both 653 | // brackets in the pair to match the embedding direction. 654 | var foundStrongType = false; 655 | var useStrongType = 0; 656 | for (var si$8 = openSeqIdx + 1; si$8 < closeSeqIdx; si$8++) { 657 | var i$15 = seqIndices$1[si$8]; 658 | if (charTypes[i$15] & STRONG_TYPES_FOR_N_STEPS) { 659 | foundStrongType = true; 660 | var lr = (charTypes[i$15] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L; 661 | if (lr === embedDirection) { 662 | useStrongType = lr; 663 | break 664 | } 665 | } 666 | } 667 | // c. Otherwise, if there is a strong type it must be opposite the embedding direction. Therefore, test 668 | // for an established context with a preceding strong type by checking backwards before the opening paired 669 | // bracket until the first strong type (L, R, or sos) is found. 670 | // 1. If the preceding strong type is also opposite the embedding direction, context is established, so 671 | // set the type for both brackets in the pair to that direction. 672 | // 2. Otherwise set the type for both brackets in the pair to the embedding direction. 673 | if (foundStrongType && !useStrongType) { 674 | useStrongType = sosType; 675 | for (var si$9 = openSeqIdx - 1; si$9 >= 0; si$9--) { 676 | var i$16 = seqIndices$1[si$9]; 677 | if (charTypes[i$16] & STRONG_TYPES_FOR_N_STEPS) { 678 | var lr$1 = (charTypes[i$16] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L; 679 | if (lr$1 !== embedDirection) { 680 | useStrongType = lr$1; 681 | } else { 682 | useStrongType = embedDirection; 683 | } 684 | break 685 | } 686 | } 687 | } 688 | if (useStrongType) { 689 | charTypes[seqIndices$1[openSeqIdx]] = charTypes[seqIndices$1[closeSeqIdx]] = useStrongType; 690 | // * Any number of characters that had original bidirectional character type NSM prior to the application 691 | // of W1 that immediately follow a paired bracket which changed to L or R under N0 should change to match 692 | // the type of their preceding bracket. 693 | if (useStrongType !== embedDirection) { 694 | for (var si$10 = openSeqIdx + 1; si$10 < seqIndices$1.length; si$10++) { 695 | if (!(charTypes[seqIndices$1[si$10]] & BN_LIKE_TYPES)) { 696 | if (getBidiCharType(string[seqIndices$1[si$10]]) & TYPE_NSM) { 697 | charTypes[seqIndices$1[si$10]] = useStrongType; 698 | } 699 | break 700 | } 701 | } 702 | } 703 | if (useStrongType !== embedDirection) { 704 | for (var si$11 = closeSeqIdx + 1; si$11 < seqIndices$1.length; si$11++) { 705 | if (!(charTypes[seqIndices$1[si$11]] & BN_LIKE_TYPES)) { 706 | if (getBidiCharType(string[seqIndices$1[si$11]]) & TYPE_NSM) { 707 | charTypes[seqIndices$1[si$11]] = useStrongType; 708 | } 709 | break 710 | } 711 | } 712 | } 713 | } 714 | } 715 | 716 | // N1. A sequence of NIs takes the direction of the surrounding strong text if the text on both sides has the 717 | // same direction. 718 | // N2. Any remaining NIs take the embedding direction. 719 | for (var si$12 = 0; si$12 < seqIndices$1.length; si$12++) { 720 | if (charTypes[seqIndices$1[si$12]] & NEUTRAL_ISOLATE_TYPES) { 721 | var niRunStart = si$12, niRunEnd = si$12; 722 | var prevType$2 = sosType; //si === 0 ? sosType : (charTypes[seqIndices[si - 1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L 723 | for (var si2 = si$12 - 1; si2 >= 0; si2--) { 724 | if (charTypes[seqIndices$1[si2]] & BN_LIKE_TYPES) { 725 | niRunStart = si2; //5.2 treat BNs adjacent to NIs as NIs 726 | } else { 727 | prevType$2 = (charTypes[seqIndices$1[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L; 728 | break 729 | } 730 | } 731 | var nextType$1 = eosType; 732 | for (var si2$1 = si$12 + 1; si2$1 < seqIndices$1.length; si2$1++) { 733 | if (charTypes[seqIndices$1[si2$1]] & (NEUTRAL_ISOLATE_TYPES | BN_LIKE_TYPES)) { 734 | niRunEnd = si2$1; 735 | } else { 736 | nextType$1 = (charTypes[seqIndices$1[si2$1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L; 737 | break 738 | } 739 | } 740 | for (var sj$7 = niRunStart; sj$7 <= niRunEnd; sj$7++) { 741 | charTypes[seqIndices$1[sj$7]] = prevType$2 === nextType$1 ? prevType$2 : embedDirection; 742 | } 743 | si$12 = niRunEnd; 744 | } 745 | } 746 | } 747 | } 748 | 749 | // === 3.3.6 Resolving Implicit Levels === 750 | 751 | for (var i$17 = paragraph.start; i$17 <= paragraph.end; i$17++) { 752 | var level$3 = embedLevels[i$17]; 753 | var type$1 = charTypes[i$17]; 754 | // I2. For all characters with an odd (right-to-left) embedding level, those of type L, EN or AN go up one level. 755 | if (level$3 & 1) { 756 | if (type$1 & (TYPE_L | TYPE_EN | TYPE_AN)) { 757 | embedLevels[i$17]++; 758 | } 759 | } 760 | // I1. For all characters with an even (left-to-right) embedding level, those of type R go up one level 761 | // and those of type AN or EN go up two levels. 762 | else { 763 | if (type$1 & TYPE_R) { 764 | embedLevels[i$17]++; 765 | } else if (type$1 & (TYPE_AN | TYPE_EN)) { 766 | embedLevels[i$17] += 2; 767 | } 768 | } 769 | 770 | // 5.2: Resolve any LRE, RLE, LRO, RLO, PDF, or BN to the level of the preceding character if there is one, 771 | // and otherwise to the base level. 772 | if (type$1 & BN_LIKE_TYPES) { 773 | embedLevels[i$17] = i$17 === 0 ? paragraph.level : embedLevels[i$17 - 1]; 774 | } 775 | 776 | // 3.4 L1.1-4: Reset the embedding level of segment/paragraph separators, and any sequence of whitespace or 777 | // isolate formatting characters preceding them or the end of the paragraph, to the paragraph level. 778 | // NOTE: this will also need to be applied to each individual line ending after line wrapping occurs. 779 | if (i$17 === paragraph.end || getBidiCharType(string[i$17]) & (TYPE_S | TYPE_B)) { 780 | for (var j$1 = i$17; j$1 >= 0 && (getBidiCharType(string[j$1]) & TRAILING_TYPES); j$1--) { 781 | embedLevels[j$1] = paragraph.level; 782 | } 783 | } 784 | } 785 | } 786 | 787 | // DONE! The resolved levels can then be used, after line wrapping, to flip runs of characters 788 | // according to section 3.4 Reordering Resolved Levels 789 | return { 790 | levels: embedLevels, 791 | paragraphs: paragraphs 792 | } 793 | 794 | function determineAutoEmbedLevel (start, isFSI) { 795 | // 3.3.1 P2 - P3 796 | for (var i = start; i < string.length; i++) { 797 | var charType = charTypes[i]; 798 | if (charType & (TYPE_R | TYPE_AL)) { 799 | return 1 800 | } 801 | if ((charType & (TYPE_B | TYPE_L)) || (isFSI && charType === TYPE_PDI)) { 802 | return 0 803 | } 804 | if (charType & ISOLATE_INIT_TYPES) { 805 | var pdi = indexOfMatchingPDI(i); 806 | i = pdi === -1 ? string.length : pdi; 807 | } 808 | } 809 | return 0 810 | } 811 | 812 | function indexOfMatchingPDI (isolateStart) { 813 | // 3.1.2 BD9 814 | var isolationLevel = 1; 815 | for (var i = isolateStart + 1; i < string.length; i++) { 816 | var charType = charTypes[i]; 817 | if (charType & TYPE_B) { 818 | break 819 | } 820 | if (charType & TYPE_PDI) { 821 | if (--isolationLevel === 0) { 822 | return i 823 | } 824 | } else if (charType & ISOLATE_INIT_TYPES) { 825 | isolationLevel++; 826 | } 827 | } 828 | return -1 829 | } 830 | } 831 | 832 | // Bidi mirrored chars data, auto generated 833 | var data = "14>1,j>2,t>2,u>2,1a>g,2v3>1,1>1,1ge>1,1wd>1,b>1,1j>1,f>1,ai>3,-2>3,+1,8>1k0,-1jq>1y7,-1y6>1hf,-1he>1h6,-1h5>1ha,-1h8>1qi,-1pu>1,6>3u,-3s>7,6>1,1>1,f>1,1>1,+2,3>1,1>1,+13,4>1,1>1,6>1eo,-1ee>1,3>1mg,-1me>1mk,-1mj>1mi,-1mg>1mi,-1md>1,1>1,+2,1>10k,-103>1,1>1,4>1,5>1,1>1,+10,3>1,1>8,-7>8,+1,-6>7,+1,a>1,1>1,u>1,u6>1,1>1,+5,26>1,1>1,2>1,2>2,8>1,7>1,4>1,1>1,+5,b8>1,1>1,+3,1>3,-2>1,2>1,1>1,+2,c>1,3>1,1>1,+2,h>1,3>1,a>1,1>1,2>1,3>1,1>1,d>1,f>1,3>1,1a>1,1>1,6>1,7>1,13>1,k>1,1>1,+19,4>1,1>1,+2,2>1,1>1,+18,m>1,a>1,1>1,lk>1,1>1,4>1,2>1,f>1,3>1,1>1,+3,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,6>1,4j>1,j>2,t>2,u>2,2>1,+1"; 834 | 835 | var mirrorMap; 836 | 837 | function parse () { 838 | if (!mirrorMap) { 839 | //const start = performance.now() 840 | var ref = parseCharacterMap(data, true); 841 | var map = ref.map; 842 | var reverseMap = ref.reverseMap; 843 | // Combine both maps into one 844 | reverseMap.forEach(function (value, key) { 845 | map.set(key, value); 846 | }); 847 | mirrorMap = map; 848 | //console.log(`mirrored chars parsed in ${performance.now() - start}ms`) 849 | } 850 | } 851 | 852 | function getMirroredCharacter (char) { 853 | parse(); 854 | return mirrorMap.get(char) || null 855 | } 856 | 857 | /** 858 | * Given a string and its resolved embedding levels, build a map of indices to replacement chars 859 | * for any characters in right-to-left segments that have defined mirrored characters. 860 | * @param string 861 | * @param embeddingLevels 862 | * @param [start] 863 | * @param [end] 864 | * @return {Map} 865 | */ 866 | function getMirroredCharactersMap(string, embeddingLevels, start, end) { 867 | var strLen = string.length; 868 | start = Math.max(0, start == null ? 0 : +start); 869 | end = Math.min(strLen - 1, end == null ? strLen - 1 : +end); 870 | 871 | var map = new Map(); 872 | for (var i = start; i <= end; i++) { 873 | if (embeddingLevels[i] & 1) { //only odd (rtl) levels 874 | var mirror = getMirroredCharacter(string[i]); 875 | if (mirror !== null) { 876 | map.set(i, mirror); 877 | } 878 | } 879 | } 880 | return map 881 | } 882 | 883 | /** 884 | * Given a start and end denoting a single line within a string, and a set of precalculated 885 | * bidi embedding levels, produce a list of segments whose ordering should be flipped, in sequence. 886 | * @param {string} string - the full input string 887 | * @param {GetEmbeddingLevelsResult} embeddingLevelsResult - the result object from getEmbeddingLevels 888 | * @param {number} [start] - first character in a subset of the full string 889 | * @param {number} [end] - last character in a subset of the full string 890 | * @return {number[][]} - the list of start/end segments that should be flipped, in order. 891 | */ 892 | function getReorderSegments(string, embeddingLevelsResult, start, end) { 893 | var strLen = string.length; 894 | start = Math.max(0, start == null ? 0 : +start); 895 | end = Math.min(strLen - 1, end == null ? strLen - 1 : +end); 896 | 897 | var segments = []; 898 | embeddingLevelsResult.paragraphs.forEach(function (paragraph) { 899 | var lineStart = Math.max(start, paragraph.start); 900 | var lineEnd = Math.min(end, paragraph.end); 901 | if (lineStart < lineEnd) { 902 | // Local slice for mutation 903 | var lineLevels = embeddingLevelsResult.levels.slice(lineStart, lineEnd + 1); 904 | 905 | // 3.4 L1.4: Reset any sequence of whitespace characters and/or isolate formatting characters at the 906 | // end of the line to the paragraph level. 907 | for (var i = lineEnd; i >= lineStart && (getBidiCharType(string[i]) & TRAILING_TYPES); i--) { 908 | lineLevels[i] = paragraph.level; 909 | } 910 | 911 | // L2. From the highest level found in the text to the lowest odd level on each line, including intermediate levels 912 | // not actually present in the text, reverse any contiguous sequence of characters that are at that level or higher. 913 | var maxLevel = paragraph.level; 914 | var minOddLevel = Infinity; 915 | for (var i$1 = 0; i$1 < lineLevels.length; i$1++) { 916 | var level = lineLevels[i$1]; 917 | if (level > maxLevel) { maxLevel = level; } 918 | if (level < minOddLevel) { minOddLevel = level | 1; } 919 | } 920 | for (var lvl = maxLevel; lvl >= minOddLevel; lvl--) { 921 | for (var i$2 = 0; i$2 < lineLevels.length; i$2++) { 922 | if (lineLevels[i$2] >= lvl) { 923 | var segStart = i$2; 924 | while (i$2 + 1 < lineLevels.length && lineLevels[i$2 + 1] >= lvl) { 925 | i$2++; 926 | } 927 | if (i$2 > segStart) { 928 | segments.push([segStart + lineStart, i$2 + lineStart]); 929 | } 930 | } 931 | } 932 | } 933 | } 934 | }); 935 | return segments 936 | } 937 | 938 | /** 939 | * @param {string} string 940 | * @param {GetEmbeddingLevelsResult} embedLevelsResult 941 | * @param {number} [start] 942 | * @param {number} [end] 943 | * @return {string} the new string with bidi segments reordered 944 | */ 945 | function getReorderedString(string, embedLevelsResult, start, end) { 946 | var indices = getReorderedIndices(string, embedLevelsResult, start, end); 947 | var chars = [].concat( string ); 948 | indices.forEach(function (charIndex, i) { 949 | chars[i] = ( 950 | (embedLevelsResult.levels[charIndex] & 1) ? getMirroredCharacter(string[charIndex]) : null 951 | ) || string[charIndex]; 952 | }); 953 | return chars.join('') 954 | } 955 | 956 | /** 957 | * @param {string} string 958 | * @param {GetEmbeddingLevelsResult} embedLevelsResult 959 | * @param {number} [start] 960 | * @param {number} [end] 961 | * @return {number[]} an array with character indices in their new bidi order 962 | */ 963 | function getReorderedIndices(string, embedLevelsResult, start, end) { 964 | var segments = getReorderSegments(string, embedLevelsResult, start, end); 965 | // Fill an array with indices 966 | var indices = []; 967 | for (var i = 0; i < string.length; i++) { 968 | indices[i] = i; 969 | } 970 | // Reverse each segment in order 971 | segments.forEach(function (ref) { 972 | var start = ref[0]; 973 | var end = ref[1]; 974 | 975 | var slice = indices.slice(start, end + 1); 976 | for (var i = slice.length; i--;) { 977 | indices[end - i] = slice[i]; 978 | } 979 | }); 980 | return indices 981 | } 982 | 983 | exports.closingToOpeningBracket = closingToOpeningBracket; 984 | exports.getBidiCharType = getBidiCharType; 985 | exports.getBidiCharTypeName = getBidiCharTypeName; 986 | exports.getCanonicalBracket = getCanonicalBracket; 987 | exports.getEmbeddingLevels = getEmbeddingLevels; 988 | exports.getMirroredCharacter = getMirroredCharacter; 989 | exports.getMirroredCharactersMap = getMirroredCharactersMap; 990 | exports.getReorderSegments = getReorderSegments; 991 | exports.getReorderedIndices = getReorderedIndices; 992 | exports.getReorderedString = getReorderedString; 993 | exports.openingToClosingBracket = openingToClosingBracket; 994 | 995 | Object.defineProperty(exports, '__esModule', { value: true }); 996 | 997 | return exports; 998 | 999 | }({})); 1000 | return bidi} 1001 | 1002 | export default bidiFactory; 1003 | -------------------------------------------------------------------------------- /example/common.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #F0F0F0; 3 | font: 1rem/1.4 -apple-system, BlinkMacSystemFont, 4 | Segoe UI, Roboto, Oxygen, 5 | Ubuntu, Cantarell, Fira Sans, 6 | Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | header { 10 | position: relative; 11 | z-index: 2; 12 | left: 0px; 13 | text-align: left; 14 | max-width: 420px; 15 | padding: 0.5em; 16 | background-color: rgba(255, 255, 255, 0.90); 17 | margin-bottom: 0.5em; 18 | border-radius: 2px; 19 | } 20 | 21 | details summary { 22 | font-size: 1.0em; 23 | font-weight: bold; 24 | } 25 | 26 | details[open] summary { 27 | font-size: 1.4em; 28 | font-weight: bold; 29 | } 30 | 31 | header h1 { 32 | margin-top: 0px; 33 | } 34 | 35 | canvas { 36 | position: absolute; 37 | z-index: 0; 38 | width: 100%; 39 | height: 100%; 40 | left: 0; 41 | top: 0; 42 | right: 0; 43 | bottom: 0; 44 | margin: 0; 45 | touch-action: none; 46 | } 47 | 48 | .back { 49 | float: right; 50 | text-decoration: none; 51 | } 52 | 53 | .back:hover { 54 | text-decoration: underline; 55 | } 56 | 57 | .back::before { 58 | display: inline-block; 59 | content: attr(data-index) '<'; 60 | font-weight: bold; 61 | white-space: nowrap; 62 | margin-right: 0.2em; 63 | margin-left: 0.2em; 64 | } 65 | 66 | /* Used for the 'barebones' samples */ 67 | .barebones-button { 68 | font-family: "Karla", sans-serif; 69 | border: rgb(80, 168, 252) 2px solid; 70 | border-radius: 2px; 71 | box-sizing: border-box; 72 | background: none; 73 | height: 55px; 74 | min-width: 176px; 75 | display: inline-block; 76 | position: relative; 77 | cursor: pointer; 78 | font-size: 18px; 79 | color: rgb(80, 168, 252); 80 | background-color: rgba(255, 255, 255, 0.7); 81 | } 82 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Shared scene demo 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | Shared scene demo 20 | This sample demonstrates using shared reference space 21 |

22 | 23 | Back 24 |

25 |
26 |
27 | 28 | 29 | 42 | 43 | 557 | 558 | 559 | -------------------------------------------------------------------------------- /example/troika-three-utils.esm.js: -------------------------------------------------------------------------------- 1 | import { ShaderChunk, UniformsUtils, MeshDepthMaterial, RGBADepthPacking, MeshDistanceMaterial, ShaderLib, Matrix4, Vector3, Mesh, CylinderGeometry, Vector2, MeshStandardMaterial, DoubleSide } from 'three'; 2 | 3 | /** 4 | * Regular expression for matching the `void main() {` opener line in GLSL. 5 | * @type {RegExp} 6 | */ 7 | const voidMainRegExp = /\bvoid\s+main\s*\(\s*\)\s*{/g; 8 | 9 | /** 10 | * Recursively expands all `#include ` statements within string of shader code. 11 | * Copied from three's WebGLProgram#parseIncludes for external use. 12 | * 13 | * @param {string} source - The GLSL source code to evaluate 14 | * @return {string} The GLSL code with all includes expanded 15 | */ 16 | function expandShaderIncludes( source ) { 17 | const pattern = /^[ \t]*#include +<([\w\d./]+)>/gm; 18 | function replace(match, include) { 19 | let chunk = ShaderChunk[include]; 20 | return chunk ? expandShaderIncludes(chunk) : match 21 | } 22 | return source.replace( pattern, replace ) 23 | } 24 | 25 | /* 26 | * This is a direct copy of MathUtils.generateUUID from Three.js, to preserve compatibility with three 27 | * versions before 0.113.0 as it was changed from Math to MathUtils in that version. 28 | * https://github.com/mrdoob/three.js/blob/dd8b5aa3b270c17096b90945cd2d6d1b13aaec53/src/math/MathUtils.js#L16 29 | */ 30 | 31 | const _lut = []; 32 | 33 | for (let i = 0; i < 256; i++) { 34 | _lut[i] = (i < 16 ? '0' : '') + (i).toString(16); 35 | } 36 | 37 | function generateUUID() { 38 | 39 | // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136 40 | 41 | const d0 = Math.random() * 0xffffffff | 0; 42 | const d1 = Math.random() * 0xffffffff | 0; 43 | const d2 = Math.random() * 0xffffffff | 0; 44 | const d3 = Math.random() * 0xffffffff | 0; 45 | const uuid = _lut[d0 & 0xff] + _lut[d0 >> 8 & 0xff] + _lut[d0 >> 16 & 0xff] + _lut[d0 >> 24 & 0xff] + '-' + 46 | _lut[d1 & 0xff] + _lut[d1 >> 8 & 0xff] + '-' + _lut[d1 >> 16 & 0x0f | 0x40] + _lut[d1 >> 24 & 0xff] + '-' + 47 | _lut[d2 & 0x3f | 0x80] + _lut[d2 >> 8 & 0xff] + '-' + _lut[d2 >> 16 & 0xff] + _lut[d2 >> 24 & 0xff] + 48 | _lut[d3 & 0xff] + _lut[d3 >> 8 & 0xff] + _lut[d3 >> 16 & 0xff] + _lut[d3 >> 24 & 0xff]; 49 | 50 | // .toUpperCase() here flattens concatenated strings to save heap memory space. 51 | return uuid.toUpperCase() 52 | 53 | } 54 | 55 | // Local assign polyfill to avoid importing troika-core 56 | const assign = Object.assign || function(/*target, ...sources*/) { 57 | let target = arguments[0]; 58 | for (let i = 1, len = arguments.length; i < len; i++) { 59 | let source = arguments[i]; 60 | if (source) { 61 | for (let prop in source) { 62 | if (Object.prototype.hasOwnProperty.call(source, prop)) { 63 | target[prop] = source[prop]; 64 | } 65 | } 66 | } 67 | } 68 | return target 69 | }; 70 | 71 | 72 | const epoch = Date.now(); 73 | const CONSTRUCTOR_CACHE = new WeakMap(); 74 | const SHADER_UPGRADE_CACHE = new Map(); 75 | 76 | // Material ids must be integers, but we can't access the increment from Three's `Material` module, 77 | // so let's choose a sufficiently large starting value that should theoretically never collide. 78 | let materialInstanceId = 1e10; 79 | 80 | /** 81 | * A utility for creating a custom shader material derived from another material's 82 | * shaders. This allows you to inject custom shader logic and transforms into the 83 | * builtin ThreeJS materials without having to recreate them from scratch. 84 | * 85 | * @param {THREE.Material} baseMaterial - the original material to derive from 86 | * 87 | * @param {Object} options - How the base material should be modified. 88 | * @param {Object=} options.defines - Custom `defines` for the material 89 | * @param {Object=} options.extensions - Custom `extensions` for the material, e.g. `{derivatives: true}` 90 | * @param {Object=} options.uniforms - Custom `uniforms` for use in the modified shader. These can 91 | * be accessed and manipulated via the resulting material's `uniforms` property, just like 92 | * in a ShaderMaterial. You do not need to repeat the base material's own uniforms here. 93 | * @param {String=} options.timeUniform - If specified, a uniform of this name will be injected into 94 | * both shaders, and it will automatically be updated on each render frame with a number of 95 | * elapsed milliseconds. The "zero" epoch time is not significant so don't rely on this as a 96 | * true calendar time. 97 | * @param {String=} options.vertexDefs - Custom GLSL code to inject into the vertex shader's top-level 98 | * definitions, above the `void main()` function. 99 | * @param {String=} options.vertexMainIntro - Custom GLSL code to inject at the top of the vertex 100 | * shader's `void main` function. 101 | * @param {String=} options.vertexMainOutro - Custom GLSL code to inject at the end of the vertex 102 | * shader's `void main` function. 103 | * @param {String=} options.vertexTransform - Custom GLSL code to manipulate the `position`, `normal`, 104 | * and/or `uv` vertex attributes. This code will be wrapped within a standalone function with 105 | * those attributes exposed by their normal names as read/write values. 106 | * @param {String=} options.fragmentDefs - Custom GLSL code to inject into the fragment shader's top-level 107 | * definitions, above the `void main()` function. 108 | * @param {String=} options.fragmentMainIntro - Custom GLSL code to inject at the top of the fragment 109 | * shader's `void main` function. 110 | * @param {String=} options.fragmentMainOutro - Custom GLSL code to inject at the end of the fragment 111 | * shader's `void main` function. You can manipulate `gl_FragColor` here but keep in mind it goes 112 | * after any of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), so if you 113 | * want those to apply to your changes use `fragmentColorTransform` instead. 114 | * @param {String=} options.fragmentColorTransform - Custom GLSL code to manipulate the `gl_FragColor` 115 | * output value. Will be injected near the end of the `void main` function, but before any 116 | * of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), and before the 117 | * `fragmentMainOutro`. 118 | * @param {function({fragmentShader: string, vertexShader:string}): 119 | * {fragmentShader: string, vertexShader:string}} options.customRewriter - A function 120 | * for performing custom rewrites of the full shader code. Useful if you need to do something 121 | * special that's not covered by the other builtin options. This function will be executed before 122 | * any other transforms are applied. 123 | * @param {boolean=} options.chained - Set to `true` to prototype-chain the derived material to the base 124 | * material, rather than the default behavior of copying it. This allows the derived material to 125 | * automatically pick up changes made to the base material and its properties. This can be useful 126 | * where the derived material is hidden from the user as an implementation detail, allowing them 127 | * to work with the original material like normal. But it can result in unexpected behavior if not 128 | * handled carefully. 129 | * 130 | * @return {THREE.Material} 131 | * 132 | * The returned material will also have two new methods, `getDepthMaterial()` and `getDistanceMaterial()`, 133 | * which can be called to get a variant of the derived material for use in shadow casting. If the 134 | * target mesh is expected to cast shadows, then you can assign these to the mesh's `customDepthMaterial` 135 | * (for directional and spot lights) and/or `customDistanceMaterial` (for point lights) properties to 136 | * allow the cast shadow to honor your derived shader's vertex transforms and discarded fragments. These 137 | * will also set a custom `#define IS_DEPTH_MATERIAL` or `#define IS_DISTANCE_MATERIAL` that you can look 138 | * for in your derived shaders with `#ifdef` to customize their behavior for the depth or distance 139 | * scenarios, e.g. skipping antialiasing or expensive shader logic. 140 | */ 141 | function createDerivedMaterial(baseMaterial, options) { 142 | // Generate a key that is unique to the content of these `options`. We'll use this 143 | // throughout for caching and for generating the upgraded shader code. This increases 144 | // the likelihood that the resulting shaders will line up across multiple calls so 145 | // their GL programs can be shared and cached. 146 | const optionsKey = getKeyForOptions(options); 147 | 148 | // First check to see if we've already derived from this baseMaterial using this 149 | // unique set of options, and if so reuse the constructor to avoid some allocations. 150 | let ctorsByDerivation = CONSTRUCTOR_CACHE.get(baseMaterial); 151 | if (!ctorsByDerivation) { 152 | CONSTRUCTOR_CACHE.set(baseMaterial, (ctorsByDerivation = Object.create(null))); 153 | } 154 | if (ctorsByDerivation[optionsKey]) { 155 | return new ctorsByDerivation[optionsKey]() 156 | } 157 | 158 | const privateBeforeCompileProp = `_onBeforeCompile${optionsKey}`; 159 | 160 | // Private onBeforeCompile handler that injects the modified shaders and uniforms when 161 | // the renderer switches to this material's program 162 | const onBeforeCompile = function (shaderInfo, renderer) { 163 | baseMaterial.onBeforeCompile.call(this, shaderInfo, renderer); 164 | 165 | // Upgrade the shaders, caching the result by incoming source code 166 | const cacheKey = this.customProgramCacheKey() + '|' + shaderInfo.vertexShader + '|' + shaderInfo.fragmentShader; 167 | let upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey]; 168 | if (!upgradedShaders) { 169 | const upgraded = upgradeShaders(this, shaderInfo, options, optionsKey); 170 | upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey] = upgraded; 171 | } 172 | 173 | // Inject upgraded shaders and uniforms into the program 174 | shaderInfo.vertexShader = upgradedShaders.vertexShader; 175 | shaderInfo.fragmentShader = upgradedShaders.fragmentShader; 176 | assign(shaderInfo.uniforms, this.uniforms); 177 | 178 | // Inject auto-updating time uniform if requested 179 | if (options.timeUniform) { 180 | shaderInfo.uniforms[options.timeUniform] = { 181 | get value() {return Date.now() - epoch} 182 | }; 183 | } 184 | 185 | // Users can still add their own handlers on top of ours 186 | if (this[privateBeforeCompileProp]) { 187 | this[privateBeforeCompileProp](shaderInfo); 188 | } 189 | }; 190 | 191 | const DerivedMaterial = function DerivedMaterial() { 192 | return derive(options.chained ? baseMaterial : baseMaterial.clone()) 193 | }; 194 | 195 | const derive = function(base) { 196 | // Prototype chain to the base material 197 | const derived = Object.create(base, descriptor); 198 | 199 | // Store the baseMaterial for reference; this is always the original even when cloning 200 | Object.defineProperty(derived, 'baseMaterial', { value: baseMaterial }); 201 | 202 | // Needs its own ids 203 | Object.defineProperty(derived, 'id', { value: materialInstanceId++ }); 204 | derived.uuid = generateUUID(); 205 | 206 | // Merge uniforms, defines, and extensions 207 | derived.uniforms = assign({}, base.uniforms, options.uniforms); 208 | derived.defines = assign({}, base.defines, options.defines); 209 | derived.defines[`TROIKA_DERIVED_MATERIAL_${optionsKey}`] = ''; //force a program change from the base material 210 | derived.extensions = assign({}, base.extensions, options.extensions); 211 | 212 | // Don't inherit EventDispatcher listeners 213 | derived._listeners = undefined; 214 | 215 | return derived 216 | }; 217 | 218 | const descriptor = { 219 | constructor: {value: DerivedMaterial}, 220 | isDerivedMaterial: {value: true}, 221 | 222 | type: { 223 | get: () => baseMaterial.type, 224 | set: (value) => {baseMaterial.type = value;} 225 | }, 226 | 227 | isDerivedFrom: { 228 | writable: true, 229 | configurable: true, 230 | value: function (testMaterial) { 231 | const base = this.baseMaterial; 232 | return testMaterial === base || (base.isDerivedMaterial && base.isDerivedFrom(testMaterial)) || false 233 | } 234 | }, 235 | 236 | customProgramCacheKey: { 237 | writable: true, 238 | configurable: true, 239 | value: function () { 240 | return baseMaterial.customProgramCacheKey() + '|' + optionsKey 241 | } 242 | }, 243 | 244 | onBeforeCompile: { 245 | get() { 246 | return onBeforeCompile 247 | }, 248 | set(fn) { 249 | this[privateBeforeCompileProp] = fn; 250 | } 251 | }, 252 | 253 | copy: { 254 | writable: true, 255 | configurable: true, 256 | value: function (source) { 257 | baseMaterial.copy.call(this, source); 258 | if (!baseMaterial.isShaderMaterial && !baseMaterial.isDerivedMaterial) { 259 | assign(this.extensions, source.extensions); 260 | assign(this.defines, source.defines); 261 | assign(this.uniforms, UniformsUtils.clone(source.uniforms)); 262 | } 263 | return this 264 | } 265 | }, 266 | 267 | clone: { 268 | writable: true, 269 | configurable: true, 270 | value: function () { 271 | const newBase = new baseMaterial.constructor(); 272 | return derive(newBase).copy(this) 273 | } 274 | }, 275 | 276 | /** 277 | * Utility to get a MeshDepthMaterial that will honor this derived material's vertex 278 | * transformations and discarded fragments. 279 | */ 280 | getDepthMaterial: { 281 | writable: true, 282 | configurable: true, 283 | value: function() { 284 | let depthMaterial = this._depthMaterial; 285 | if (!depthMaterial) { 286 | depthMaterial = this._depthMaterial = createDerivedMaterial( 287 | baseMaterial.isDerivedMaterial 288 | ? baseMaterial.getDepthMaterial() 289 | : new MeshDepthMaterial({ depthPacking: RGBADepthPacking }), 290 | options 291 | ); 292 | depthMaterial.defines.IS_DEPTH_MATERIAL = ''; 293 | depthMaterial.uniforms = this.uniforms; //automatically recieve same uniform values 294 | } 295 | return depthMaterial 296 | } 297 | }, 298 | 299 | /** 300 | * Utility to get a MeshDistanceMaterial that will honor this derived material's vertex 301 | * transformations and discarded fragments. 302 | */ 303 | getDistanceMaterial: { 304 | writable: true, 305 | configurable: true, 306 | value: function() { 307 | let distanceMaterial = this._distanceMaterial; 308 | if (!distanceMaterial) { 309 | distanceMaterial = this._distanceMaterial = createDerivedMaterial( 310 | baseMaterial.isDerivedMaterial 311 | ? baseMaterial.getDistanceMaterial() 312 | : new MeshDistanceMaterial(), 313 | options 314 | ); 315 | distanceMaterial.defines.IS_DISTANCE_MATERIAL = ''; 316 | distanceMaterial.uniforms = this.uniforms; //automatically recieve same uniform values 317 | } 318 | return distanceMaterial 319 | } 320 | }, 321 | 322 | dispose: { 323 | writable: true, 324 | configurable: true, 325 | value() { 326 | const {_depthMaterial, _distanceMaterial} = this; 327 | if (_depthMaterial) _depthMaterial.dispose(); 328 | if (_distanceMaterial) _distanceMaterial.dispose(); 329 | baseMaterial.dispose.call(this); 330 | } 331 | } 332 | }; 333 | 334 | ctorsByDerivation[optionsKey] = DerivedMaterial; 335 | return new DerivedMaterial() 336 | } 337 | 338 | 339 | function upgradeShaders(material, {vertexShader, fragmentShader}, options, key) { 340 | let { 341 | vertexDefs, 342 | vertexMainIntro, 343 | vertexMainOutro, 344 | vertexTransform, 345 | fragmentDefs, 346 | fragmentMainIntro, 347 | fragmentMainOutro, 348 | fragmentColorTransform, 349 | customRewriter, 350 | timeUniform 351 | } = options; 352 | 353 | vertexDefs = vertexDefs || ''; 354 | vertexMainIntro = vertexMainIntro || ''; 355 | vertexMainOutro = vertexMainOutro || ''; 356 | fragmentDefs = fragmentDefs || ''; 357 | fragmentMainIntro = fragmentMainIntro || ''; 358 | fragmentMainOutro = fragmentMainOutro || ''; 359 | 360 | // Expand includes if needed 361 | if (vertexTransform || customRewriter) { 362 | vertexShader = expandShaderIncludes(vertexShader); 363 | } 364 | if (fragmentColorTransform || customRewriter) { 365 | // We need to be able to find postprocessing chunks after include expansion in order to 366 | // put them after the fragmentColorTransform, so mark them with comments first. Even if 367 | // this particular derivation doesn't have a fragmentColorTransform, other derivations may, 368 | // so we still mark them. 369 | fragmentShader = fragmentShader.replace( 370 | /^[ \t]*#include <((?:tonemapping|encodings|colorspace|fog|premultiplied_alpha|dithering)_fragment)>/gm, 371 | '\n//!BEGIN_POST_CHUNK $1\n$&\n//!END_POST_CHUNK\n' 372 | ); 373 | fragmentShader = expandShaderIncludes(fragmentShader); 374 | } 375 | 376 | // Apply custom rewriter function 377 | if (customRewriter) { 378 | let res = customRewriter({vertexShader, fragmentShader}); 379 | vertexShader = res.vertexShader; 380 | fragmentShader = res.fragmentShader; 381 | } 382 | 383 | // The fragmentColorTransform needs to go before any postprocessing chunks, so extract 384 | // those and re-insert them into the outro in the correct place: 385 | if (fragmentColorTransform) { 386 | let postChunks = []; 387 | fragmentShader = fragmentShader.replace( 388 | /^\/\/!BEGIN_POST_CHUNK[^]+?^\/\/!END_POST_CHUNK/gm, // [^]+? = non-greedy match of any chars including newlines 389 | match => { 390 | postChunks.push(match); 391 | return '' 392 | } 393 | ); 394 | fragmentMainOutro = `${fragmentColorTransform}\n${postChunks.join('\n')}\n${fragmentMainOutro}`; 395 | } 396 | 397 | // Inject auto-updating time uniform if requested 398 | if (timeUniform) { 399 | const code = `\nuniform float ${timeUniform};\n`; 400 | vertexDefs = code + vertexDefs; 401 | fragmentDefs = code + fragmentDefs; 402 | } 403 | 404 | // Inject a function for the vertexTransform and rename all usages of position/normal/uv 405 | if (vertexTransform) { 406 | // Hoist these defs to the very top so they work in other function defs 407 | vertexShader = `vec3 troika_position_${key}; 408 | vec3 troika_normal_${key}; 409 | vec2 troika_uv_${key}; 410 | ${vertexShader} 411 | `; 412 | vertexDefs = `${vertexDefs} 413 | void troikaVertexTransform${key}(inout vec3 position, inout vec3 normal, inout vec2 uv) { 414 | ${vertexTransform} 415 | } 416 | `; 417 | vertexMainIntro = ` 418 | troika_position_${key} = vec3(position); 419 | troika_normal_${key} = vec3(normal); 420 | troika_uv_${key} = vec2(uv); 421 | troikaVertexTransform${key}(troika_position_${key}, troika_normal_${key}, troika_uv_${key}); 422 | ${vertexMainIntro} 423 | `; 424 | vertexShader = vertexShader.replace(/\b(position|normal|uv)\b/g, (match, match1, index, fullStr) => { 425 | return /\battribute\s+vec[23]\s+$/.test(fullStr.substr(0, index)) ? match1 : `troika_${match1}_${key}` 426 | }); 427 | 428 | // Three r152 introduced the MAP_UV token, replace it too if it's pointing to the main 'uv' 429 | // Perhaps the other textures too going forward? 430 | if (!(material.map && material.map.channel > 0)) { 431 | vertexShader = vertexShader.replace(/\bMAP_UV\b/g, `troika_uv_${key}`); 432 | } 433 | } 434 | 435 | // Inject defs and intro/outro snippets 436 | vertexShader = injectIntoShaderCode(vertexShader, key, vertexDefs, vertexMainIntro, vertexMainOutro); 437 | fragmentShader = injectIntoShaderCode(fragmentShader, key, fragmentDefs, fragmentMainIntro, fragmentMainOutro); 438 | 439 | return { 440 | vertexShader, 441 | fragmentShader 442 | } 443 | } 444 | 445 | function injectIntoShaderCode(shaderCode, id, defs, intro, outro) { 446 | if (intro || outro || defs) { 447 | shaderCode = shaderCode.replace(voidMainRegExp, ` 448 | ${defs} 449 | void troikaOrigMain${id}() {` 450 | ); 451 | shaderCode += ` 452 | void main() { 453 | ${intro} 454 | troikaOrigMain${id}(); 455 | ${outro} 456 | }`; 457 | } 458 | return shaderCode 459 | } 460 | 461 | 462 | function optionsJsonReplacer(key, value) { 463 | return key === 'uniforms' ? undefined : typeof value === 'function' ? value.toString() : value 464 | } 465 | 466 | let _idCtr = 0; 467 | const optionsHashesToIds = new Map(); 468 | function getKeyForOptions(options) { 469 | const optionsHash = JSON.stringify(options, optionsJsonReplacer); 470 | let id = optionsHashesToIds.get(optionsHash); 471 | if (id == null) { 472 | optionsHashesToIds.set(optionsHash, (id = ++_idCtr)); 473 | } 474 | return id 475 | } 476 | 477 | // Copied from threejs WebGLPrograms.js so we can resolve builtin materials to their shaders 478 | // TODO how can we keep this from getting stale? 479 | const MATERIAL_TYPES_TO_SHADERS = { 480 | MeshDepthMaterial: 'depth', 481 | MeshDistanceMaterial: 'distanceRGBA', 482 | MeshNormalMaterial: 'normal', 483 | MeshBasicMaterial: 'basic', 484 | MeshLambertMaterial: 'lambert', 485 | MeshPhongMaterial: 'phong', 486 | MeshToonMaterial: 'toon', 487 | MeshStandardMaterial: 'physical', 488 | MeshPhysicalMaterial: 'physical', 489 | MeshMatcapMaterial: 'matcap', 490 | LineBasicMaterial: 'basic', 491 | LineDashedMaterial: 'dashed', 492 | PointsMaterial: 'points', 493 | ShadowMaterial: 'shadow', 494 | SpriteMaterial: 'sprite' 495 | }; 496 | 497 | /** 498 | * Given a Three.js `Material` instance, find the shaders/uniforms that will be 499 | * used to render that material. 500 | * 501 | * @param material - the Material instance 502 | * @return {object} - the material's shader info: `{uniforms:{}, fragmentShader:'', vertexShader:''}` 503 | */ 504 | function getShadersForMaterial(material) { 505 | let builtinType = MATERIAL_TYPES_TO_SHADERS[material.type]; 506 | return builtinType ? ShaderLib[builtinType] : material //TODO fallback for unknown type? 507 | } 508 | 509 | /** 510 | * Find all uniforms and their types within a shader code string. 511 | * 512 | * @param {string} shader - The shader code to parse 513 | * @return {object} mapping of uniform names to their glsl type 514 | */ 515 | function getShaderUniformTypes(shader) { 516 | let uniformRE = /\buniform\s+(int|float|vec[234]|mat[34])\s+([A-Za-z_][\w]*)/g; 517 | let uniforms = Object.create(null); 518 | let match; 519 | while ((match = uniformRE.exec(shader)) !== null) { 520 | uniforms[match[2]] = match[1]; 521 | } 522 | return uniforms 523 | } 524 | 525 | /** 526 | * Helper for smoothing out the `m.getInverse(x)` --> `m.copy(x).invert()` conversion 527 | * that happened in ThreeJS r123. 528 | * @param {Matrix4} srcMatrix 529 | * @param {Matrix4} [tgtMatrix] 530 | */ 531 | function invertMatrix4(srcMatrix, tgtMatrix = new Matrix4()) { 532 | if (typeof tgtMatrix.invert === 'function') { 533 | tgtMatrix.copy(srcMatrix).invert(); 534 | } else { 535 | tgtMatrix.getInverse(srcMatrix); 536 | } 537 | return tgtMatrix 538 | } 539 | 540 | /* 541 | Input geometry is a cylinder with r=1, height in y dimension from 0 to 1, 542 | divided into a reasonable number of height segments. 543 | */ 544 | 545 | const vertexDefs = ` 546 | uniform vec3 pointA; 547 | uniform vec3 controlA; 548 | uniform vec3 controlB; 549 | uniform vec3 pointB; 550 | uniform float radius; 551 | varying float bezierT; 552 | 553 | vec3 cubicBezier(vec3 p1, vec3 c1, vec3 c2, vec3 p2, float t) { 554 | float t2 = 1.0 - t; 555 | float b0 = t2 * t2 * t2; 556 | float b1 = 3.0 * t * t2 * t2; 557 | float b2 = 3.0 * t * t * t2; 558 | float b3 = t * t * t; 559 | return b0 * p1 + b1 * c1 + b2 * c2 + b3 * p2; 560 | } 561 | 562 | vec3 cubicBezierDerivative(vec3 p1, vec3 c1, vec3 c2, vec3 p2, float t) { 563 | float t2 = 1.0 - t; 564 | return -3.0 * p1 * t2 * t2 + 565 | c1 * (3.0 * t2 * t2 - 6.0 * t2 * t) + 566 | c2 * (6.0 * t2 * t - 3.0 * t * t) + 567 | 3.0 * p2 * t * t; 568 | } 569 | `; 570 | 571 | const vertexTransform = ` 572 | float t = position.y; 573 | bezierT = t; 574 | vec3 bezierCenterPos = cubicBezier(pointA, controlA, controlB, pointB, t); 575 | vec3 bezierDir = normalize(cubicBezierDerivative(pointA, controlA, controlB, pointB, t)); 576 | 577 | // Make "sideways" always perpendicular to the camera ray; this ensures that any twists 578 | // in the cylinder occur where you won't see them: 579 | vec3 viewDirection = normalMatrix * vec3(0.0, 0.0, 1.0); 580 | if (bezierDir == viewDirection) { 581 | bezierDir = normalize(cubicBezierDerivative(pointA, controlA, controlB, pointB, t == 1.0 ? t - 0.0001 : t + 0.0001)); 582 | } 583 | vec3 sideways = normalize(cross(bezierDir, viewDirection)); 584 | vec3 upish = normalize(cross(sideways, bezierDir)); 585 | 586 | // Build a matrix for transforming this disc in the cylinder: 587 | mat4 discTx; 588 | discTx[0].xyz = sideways * radius; 589 | discTx[1].xyz = bezierDir * radius; 590 | discTx[2].xyz = upish * radius; 591 | discTx[3].xyz = bezierCenterPos; 592 | discTx[3][3] = 1.0; 593 | 594 | // Apply transform, ignoring original y 595 | position = (discTx * vec4(position.x, 0.0, position.z, 1.0)).xyz; 596 | normal = normalize(mat3(discTx) * normal); 597 | `; 598 | 599 | const fragmentDefs = ` 600 | uniform vec3 dashing; 601 | varying float bezierT; 602 | `; 603 | 604 | const fragmentMainIntro = ` 605 | if (dashing.x + dashing.y > 0.0) { 606 | float dashFrac = mod(bezierT - dashing.z, dashing.x + dashing.y); 607 | if (dashFrac > dashing.x) { 608 | discard; 609 | } 610 | } 611 | `; 612 | 613 | // Debugging: separate color for each of the 6 sides: 614 | // const fragmentColorTransform = ` 615 | // float sideNum = floor(vUV.x * 6.0); 616 | // vec3 mixColor = sideNum < 1.0 ? vec3(1.0, 0.0, 0.0) : 617 | // sideNum < 2.0 ? vec3(0.0, 1.0, 1.0) : 618 | // sideNum < 3.0 ? vec3(1.0, 1.0, 0.0) : 619 | // sideNum < 4.0 ? vec3(0.0, 0.0, 1.0) : 620 | // sideNum < 5.0 ? vec3(0.0, 1.0, 0.0) : 621 | // vec3(1.0, 0.0, 1.0); 622 | // gl_FragColor.xyz = mix(gl_FragColor.xyz, mixColor, 0.5); 623 | // ` 624 | 625 | 626 | 627 | function createBezierMeshMaterial(baseMaterial) { 628 | return createDerivedMaterial( 629 | baseMaterial, 630 | { 631 | chained: true, 632 | uniforms: { 633 | pointA: {value: new Vector3()}, 634 | controlA: {value: new Vector3()}, 635 | controlB: {value: new Vector3()}, 636 | pointB: {value: new Vector3()}, 637 | radius: {value: 0.01}, 638 | dashing: {value: new Vector3()} //on, off, offset 639 | }, 640 | vertexDefs, 641 | vertexTransform, 642 | fragmentDefs, 643 | fragmentMainIntro 644 | } 645 | ) 646 | } 647 | 648 | let geometry = null; 649 | 650 | const defaultBaseMaterial = /*#__PURE__*/new MeshStandardMaterial({color: 0xffffff, side: DoubleSide}); 651 | 652 | 653 | /** 654 | * A ThreeJS `Mesh` that bends a tube shape along a 3D cubic bezier path. The bending is done 655 | * by deforming a straight cylindrical geometry in the vertex shader based on a set of four 656 | * control point uniforms. It patches the necessary GLSL into the mesh's assigned `material` 657 | * automatically. 658 | * 659 | * The cubiz bezier path is determined by its four `Vector3` properties: 660 | * - `pointA` 661 | * - `controlA` 662 | * - `controlB` 663 | * - `pointB` 664 | * 665 | * The tube's radius is controlled by its `radius` property, which defaults to `0.01`. 666 | * 667 | * You can also give the tube a dashed appearance with two properties: 668 | * 669 | * - `dashArray` - an array of two numbers, defining the length of "on" and "off" parts of 670 | * the dash. Each is a 0-1 ratio of the entire path's length. (Actually this is the `t` length 671 | * used as input to the cubic bezier function, not its visible length.) 672 | * - `dashOffset` - offset of where the dash starts. You can animate this to make the dashes move. 673 | * 674 | * Note that the dashes will appear like a hollow tube, not solid. This will be more apparent on 675 | * thicker tubes. 676 | * 677 | * TODO: proper geometry bounding sphere and raycasting 678 | * TODO: allow control of the geometry's segment counts 679 | */ 680 | class BezierMesh extends Mesh { 681 | static getGeometry() { 682 | return geometry || (geometry = 683 | new CylinderGeometry(1, 1, 1, 6, 64).translate(0, 0.5, 0) 684 | ) 685 | } 686 | 687 | constructor() { 688 | super( 689 | BezierMesh.getGeometry(), 690 | defaultBaseMaterial 691 | ); 692 | 693 | this.pointA = new Vector3(); 694 | this.controlA = new Vector3(); 695 | this.controlB = new Vector3(); 696 | this.pointB = new Vector3(); 697 | this.radius = 0.01; 698 | this.dashArray = new Vector2(); 699 | this.dashOffset = 0; 700 | 701 | // TODO - disabling frustum culling until I figure out how to customize the 702 | // geometry's bounding sphere that gets used 703 | this.frustumCulled = false; 704 | } 705 | 706 | // Handler for automatically wrapping the base material with our upgrades. We do the wrapping 707 | // lazily on _read_ rather than write to avoid unnecessary wrapping on transient values. 708 | get material() { 709 | let derivedMaterial = this._derivedMaterial; 710 | const baseMaterial = this._baseMaterial || this._defaultMaterial || (this._defaultMaterial = defaultBaseMaterial.clone()); 711 | if (!derivedMaterial || derivedMaterial.baseMaterial !== baseMaterial) { 712 | derivedMaterial = this._derivedMaterial = createBezierMeshMaterial(baseMaterial); 713 | // dispose the derived material when its base material is disposed: 714 | baseMaterial.addEventListener('dispose', function onDispose() { 715 | baseMaterial.removeEventListener('dispose', onDispose); 716 | derivedMaterial.dispose(); 717 | }); 718 | } 719 | return derivedMaterial 720 | } 721 | set material(baseMaterial) { 722 | this._baseMaterial = baseMaterial; 723 | } 724 | 725 | // Create and update material for shadows upon request: 726 | get customDepthMaterial() { 727 | return this.material.getDepthMaterial() 728 | } 729 | get customDistanceMaterial() { 730 | return this.material.getDistanceMaterial() 731 | } 732 | 733 | onBeforeRender() { 734 | const {uniforms} = this.material; 735 | const {pointA, controlA, controlB, pointB, radius, dashArray, dashOffset} = this; 736 | uniforms.pointA.value.copy(pointA); 737 | uniforms.controlA.value.copy(controlA); 738 | uniforms.controlB.value.copy(controlB); 739 | uniforms.pointB.value.copy(pointB); 740 | uniforms.radius.value = radius; 741 | uniforms.dashing.value.set(dashArray.x, dashArray.y, dashOffset || 0); 742 | } 743 | 744 | raycast(/*raycaster, intersects*/) { 745 | // TODO - just fail for now 746 | } 747 | } 748 | 749 | export { BezierMesh, createDerivedMaterial, expandShaderIncludes, getShaderUniformTypes, getShadersForMaterial, invertMatrix4, voidMainRegExp }; 750 | -------------------------------------------------------------------------------- /example/troika-worker-utils.esm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main content for the worker that handles the loading and execution of 3 | * modules within it. 4 | */ 5 | function workerBootstrap() { 6 | var modules = Object.create(null); 7 | 8 | // Handle messages for registering a module 9 | function registerModule(ref, callback) { 10 | var id = ref.id; 11 | var name = ref.name; 12 | var dependencies = ref.dependencies; if ( dependencies === void 0 ) dependencies = []; 13 | var init = ref.init; if ( init === void 0 ) init = function(){}; 14 | var getTransferables = ref.getTransferables; if ( getTransferables === void 0 ) getTransferables = null; 15 | 16 | // Only register once 17 | if (modules[id]) { return } 18 | 19 | try { 20 | // If any dependencies are modules, ensure they're registered and grab their value 21 | dependencies = dependencies.map(function (dep) { 22 | if (dep && dep.isWorkerModule) { 23 | registerModule(dep, function (depResult) { 24 | if (depResult instanceof Error) { throw depResult } 25 | }); 26 | dep = modules[dep.id].value; 27 | } 28 | return dep 29 | }); 30 | 31 | // Rehydrate functions 32 | init = rehydrate(("<" + name + ">.init"), init); 33 | if (getTransferables) { 34 | getTransferables = rehydrate(("<" + name + ">.getTransferables"), getTransferables); 35 | } 36 | 37 | // Initialize the module and store its value 38 | var value = null; 39 | if (typeof init === 'function') { 40 | value = init.apply(void 0, dependencies); 41 | } else { 42 | console.error('worker module init function failed to rehydrate'); 43 | } 44 | modules[id] = { 45 | id: id, 46 | value: value, 47 | getTransferables: getTransferables 48 | }; 49 | callback(value); 50 | } catch(err) { 51 | if (!(err && err.noLog)) { 52 | console.error(err); 53 | } 54 | callback(err); 55 | } 56 | } 57 | 58 | // Handle messages for calling a registered module's result function 59 | function callModule(ref, callback) { 60 | var ref$1; 61 | 62 | var id = ref.id; 63 | var args = ref.args; 64 | if (!modules[id] || typeof modules[id].value !== 'function') { 65 | callback(new Error(("Worker module " + id + ": not found or its 'init' did not return a function"))); 66 | } 67 | try { 68 | var result = (ref$1 = modules[id]).value.apply(ref$1, args); 69 | if (result && typeof result.then === 'function') { 70 | result.then(handleResult, function (rej) { return callback(rej instanceof Error ? rej : new Error('' + rej)); }); 71 | } else { 72 | handleResult(result); 73 | } 74 | } catch(err) { 75 | callback(err); 76 | } 77 | function handleResult(result) { 78 | try { 79 | var tx = modules[id].getTransferables && modules[id].getTransferables(result); 80 | if (!tx || !Array.isArray(tx) || !tx.length) { 81 | tx = undefined; //postMessage is very picky about not passing null or empty transferables 82 | } 83 | callback(result, tx); 84 | } catch(err) { 85 | console.error(err); 86 | callback(err); 87 | } 88 | } 89 | } 90 | 91 | function rehydrate(name, str) { 92 | var result = void 0; 93 | self.troikaDefine = function (r) { return result = r; }; 94 | var url = URL.createObjectURL( 95 | new Blob( 96 | [("/** " + (name.replace(/\*/g, '')) + " **/\n\ntroikaDefine(\n" + str + "\n)")], 97 | {type: 'application/javascript'} 98 | ) 99 | ); 100 | try { 101 | importScripts(url); 102 | } catch(err) { 103 | console.error(err); 104 | } 105 | URL.revokeObjectURL(url); 106 | delete self.troikaDefine; 107 | return result 108 | } 109 | 110 | // Handler for all messages within the worker 111 | self.addEventListener('message', function (e) { 112 | var ref = e.data; 113 | var messageId = ref.messageId; 114 | var action = ref.action; 115 | var data = ref.data; 116 | try { 117 | // Module registration 118 | if (action === 'registerModule') { 119 | registerModule(data, function (result) { 120 | if (result instanceof Error) { 121 | postMessage({ 122 | messageId: messageId, 123 | success: false, 124 | error: result.message 125 | }); 126 | } else { 127 | postMessage({ 128 | messageId: messageId, 129 | success: true, 130 | result: {isCallable: typeof result === 'function'} 131 | }); 132 | } 133 | }); 134 | } 135 | // Invocation 136 | if (action === 'callModule') { 137 | callModule(data, function (result, transferables) { 138 | if (result instanceof Error) { 139 | postMessage({ 140 | messageId: messageId, 141 | success: false, 142 | error: result.message 143 | }); 144 | } else { 145 | postMessage({ 146 | messageId: messageId, 147 | success: true, 148 | result: result 149 | }, transferables || undefined); 150 | } 151 | }); 152 | } 153 | } catch(err) { 154 | postMessage({ 155 | messageId: messageId, 156 | success: false, 157 | error: err.stack 158 | }); 159 | } 160 | }); 161 | } 162 | 163 | /** 164 | * Fallback for `defineWorkerModule` that behaves identically but runs in the main 165 | * thread, for when the execution environment doesn't support web workers or they 166 | * are disallowed due to e.g. CSP security restrictions. 167 | */ 168 | function defineMainThreadModule(options) { 169 | var moduleFunc = function() { 170 | var args = [], len = arguments.length; 171 | while ( len-- ) args[ len ] = arguments[ len ]; 172 | 173 | return moduleFunc._getInitResult().then(function (initResult) { 174 | if (typeof initResult === 'function') { 175 | return initResult.apply(void 0, args) 176 | } else { 177 | throw new Error('Worker module function was called but `init` did not return a callable function') 178 | } 179 | }) 180 | }; 181 | moduleFunc._getInitResult = function() { 182 | // We can ignore getTransferables in main thread. TODO workerId? 183 | var dependencies = options.dependencies; 184 | var init = options.init; 185 | 186 | // Resolve dependencies 187 | dependencies = Array.isArray(dependencies) ? dependencies.map(function (dep) { 188 | if (dep) { 189 | // If it's a worker module, use its main thread impl 190 | dep = dep.onMainThread || dep; 191 | // If it's a main thread worker module, use its init return value 192 | if (dep._getInitResult) { 193 | dep = dep._getInitResult(); 194 | } 195 | } 196 | return dep 197 | }) : []; 198 | 199 | // Invoke init with the resolved dependencies 200 | var initPromise = Promise.all(dependencies).then(function (deps) { 201 | return init.apply(null, deps) 202 | }); 203 | 204 | // Cache the resolved promise for subsequent calls 205 | moduleFunc._getInitResult = function () { return initPromise; }; 206 | 207 | return initPromise 208 | }; 209 | return moduleFunc 210 | } 211 | 212 | var supportsWorkers = function () { 213 | var supported = false; 214 | 215 | // Only attempt worker initialization in browsers; elsewhere it would just be 216 | // noise e.g. loading into a Node environment for SSR. 217 | if (typeof window !== 'undefined' && typeof window.document !== 'undefined') { 218 | try { 219 | // TODO additional checks for things like importScripts within the worker? 220 | // Would need to be an async check. 221 | var worker = new Worker( 222 | URL.createObjectURL(new Blob([''], { type: 'application/javascript' })) 223 | ); 224 | worker.terminate(); 225 | supported = true; 226 | } catch (err) { 227 | if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') ; else { 228 | console.log( 229 | ("Troika createWorkerModule: web workers not allowed; falling back to main thread execution. Cause: [" + (err.message) + "]") 230 | ); 231 | } 232 | } 233 | } 234 | 235 | // Cached result 236 | supportsWorkers = function () { return supported; }; 237 | return supported 238 | }; 239 | 240 | var _workerModuleId = 0; 241 | var _messageId = 0; 242 | var _allowInitAsString = false; 243 | var workers = Object.create(null); 244 | var registeredModules = Object.create(null); //workerId -> Set 245 | var openRequests = Object.create(null); 246 | 247 | 248 | /** 249 | * Define a module of code that will be executed with a web worker. This provides a simple 250 | * interface for moving chunks of logic off the main thread, and managing their dependencies 251 | * among one another. 252 | * 253 | * @param {object} options 254 | * @param {function} options.init 255 | * @param {array} [options.dependencies] 256 | * @param {function} [options.getTransferables] 257 | * @param {string} [options.name] 258 | * @param {string} [options.workerId] 259 | * @return {function(...[*]): {then}} 260 | */ 261 | function defineWorkerModule(options) { 262 | if ((!options || typeof options.init !== 'function') && !_allowInitAsString) { 263 | throw new Error('requires `options.init` function') 264 | } 265 | var dependencies = options.dependencies; 266 | var init = options.init; 267 | var getTransferables = options.getTransferables; 268 | var workerId = options.workerId; 269 | 270 | var onMainThread = defineMainThreadModule(options); 271 | 272 | if (workerId == null) { 273 | workerId = '#default'; 274 | } 275 | var id = "workerModule" + (++_workerModuleId); 276 | var name = options.name || id; 277 | var registrationPromise = null; 278 | 279 | dependencies = dependencies && dependencies.map(function (dep) { 280 | // Wrap raw functions as worker modules with no dependencies 281 | if (typeof dep === 'function' && !dep.workerModuleData) { 282 | _allowInitAsString = true; 283 | dep = defineWorkerModule({ 284 | workerId: workerId, 285 | name: ("<" + name + "> function dependency: " + (dep.name)), 286 | init: ("function(){return (\n" + (stringifyFunction(dep)) + "\n)}") 287 | }); 288 | _allowInitAsString = false; 289 | } 290 | // Grab postable data for worker modules 291 | if (dep && dep.workerModuleData) { 292 | dep = dep.workerModuleData; 293 | } 294 | return dep 295 | }); 296 | 297 | function moduleFunc() { 298 | var args = [], len = arguments.length; 299 | while ( len-- ) args[ len ] = arguments[ len ]; 300 | 301 | if (!supportsWorkers()) { 302 | return onMainThread.apply(void 0, args) 303 | } 304 | 305 | // Register this module if needed 306 | if (!registrationPromise) { 307 | registrationPromise = callWorker(workerId,'registerModule', moduleFunc.workerModuleData); 308 | var unregister = function () { 309 | registrationPromise = null; 310 | registeredModules[workerId].delete(unregister); 311 | } 312 | ;(registeredModules[workerId] || (registeredModules[workerId] = new Set())).add(unregister); 313 | } 314 | 315 | // Invoke the module, returning a promise 316 | return registrationPromise.then(function (ref) { 317 | var isCallable = ref.isCallable; 318 | 319 | if (isCallable) { 320 | return callWorker(workerId,'callModule', {id: id, args: args}) 321 | } else { 322 | throw new Error('Worker module function was called but `init` did not return a callable function') 323 | } 324 | }) 325 | } 326 | moduleFunc.workerModuleData = { 327 | isWorkerModule: true, 328 | id: id, 329 | name: name, 330 | dependencies: dependencies, 331 | init: stringifyFunction(init), 332 | getTransferables: getTransferables && stringifyFunction(getTransferables) 333 | }; 334 | 335 | moduleFunc.onMainThread = onMainThread; 336 | 337 | return moduleFunc 338 | } 339 | 340 | /** 341 | * Terminate an active Worker by a workerId that was passed to defineWorkerModule. 342 | * This only terminates the Worker itself; the worker module will remain available 343 | * and if you call it again its Worker will be respawned. 344 | * @param {string} workerId 345 | */ 346 | function terminateWorker(workerId) { 347 | // Unregister all modules that were registered in that worker 348 | if (registeredModules[workerId]) { 349 | registeredModules[workerId].forEach(function (unregister) { 350 | unregister(); 351 | }); 352 | } 353 | // Terminate the Worker object 354 | if (workers[workerId]) { 355 | workers[workerId].terminate(); 356 | delete workers[workerId]; 357 | } 358 | } 359 | 360 | /** 361 | * Stringifies a function into a form that can be deserialized in the worker 362 | * @param fn 363 | */ 364 | function stringifyFunction(fn) { 365 | var str = fn.toString(); 366 | // If it was defined in object method/property format, it needs to be modified 367 | if (!/^function/.test(str) && /^\w+\s*\(/.test(str)) { 368 | str = 'function ' + str; 369 | } 370 | return str 371 | } 372 | 373 | 374 | function getWorker(workerId) { 375 | var worker = workers[workerId]; 376 | if (!worker) { 377 | // Bootstrap the worker's content 378 | var bootstrap = stringifyFunction(workerBootstrap); 379 | 380 | // Create the worker from the bootstrap function content 381 | worker = workers[workerId] = new Worker( 382 | URL.createObjectURL( 383 | new Blob( 384 | [("/** Worker Module Bootstrap: " + (workerId.replace(/\*/g, '')) + " **/\n\n;(" + bootstrap + ")()")], 385 | {type: 'application/javascript'} 386 | ) 387 | ) 388 | ); 389 | 390 | // Single handler for response messages from the worker 391 | worker.onmessage = function (e) { 392 | var response = e.data; 393 | var msgId = response.messageId; 394 | var callback = openRequests[msgId]; 395 | if (!callback) { 396 | throw new Error('WorkerModule response with empty or unknown messageId') 397 | } 398 | delete openRequests[msgId]; 399 | callback(response); 400 | }; 401 | } 402 | return worker 403 | } 404 | 405 | // Issue a call to the worker with a callback to handle the response 406 | function callWorker(workerId, action, data) { 407 | return new Promise(function (resolve, reject) { 408 | var messageId = ++_messageId; 409 | openRequests[messageId] = function (response) { 410 | if (response.success) { 411 | resolve(response.result); 412 | } else { 413 | reject(new Error(("Error in worker " + action + " call: " + (response.error)))); 414 | } 415 | }; 416 | getWorker(workerId).postMessage({ 417 | messageId: messageId, 418 | action: action, 419 | data: data 420 | }); 421 | }) 422 | } 423 | 424 | export { defineWorkerModule, stringifyFunction, terminateWorker }; 425 | -------------------------------------------------------------------------------- /example/webgl-sdf-generator.mjs: -------------------------------------------------------------------------------- 1 | function SDFGenerator() { 2 | var exports = (function (exports) { 3 | 4 | /** 5 | * Find the point on a quadratic bezier curve at t where t is in the range [0, 1] 6 | */ 7 | function pointOnQuadraticBezier (x0, y0, x1, y1, x2, y2, t, pointOut) { 8 | var t2 = 1 - t; 9 | pointOut.x = t2 * t2 * x0 + 2 * t2 * t * x1 + t * t * x2; 10 | pointOut.y = t2 * t2 * y0 + 2 * t2 * t * y1 + t * t * y2; 11 | } 12 | 13 | /** 14 | * Find the point on a cubic bezier curve at t where t is in the range [0, 1] 15 | */ 16 | function pointOnCubicBezier (x0, y0, x1, y1, x2, y2, x3, y3, t, pointOut) { 17 | var t2 = 1 - t; 18 | pointOut.x = t2 * t2 * t2 * x0 + 3 * t2 * t2 * t * x1 + 3 * t2 * t * t * x2 + t * t * t * x3; 19 | pointOut.y = t2 * t2 * t2 * y0 + 3 * t2 * t2 * t * y1 + 3 * t2 * t * t * y2 + t * t * t * y3; 20 | } 21 | 22 | /** 23 | * Parse a path string into its constituent line/curve commands, invoking a callback for each. 24 | * @param {string} pathString - An SVG-like path string to parse; should only contain commands: M/L/Q/C/Z 25 | * @param {function( 26 | * command: 'L'|'Q'|'C', 27 | * startX: number, 28 | * startY: number, 29 | * endX: number, 30 | * endY: number, 31 | * ctrl1X?: number, 32 | * ctrl1Y?: number, 33 | * ctrl2X?: number, 34 | * ctrl2Y?: number 35 | * )} commandCallback - A callback function that will be called once for each parsed path command, passing the 36 | * command identifier (only L/Q/C commands) and its numeric arguments. 37 | */ 38 | function forEachPathCommand(pathString, commandCallback) { 39 | var segmentRE = /([MLQCZ])([^MLQCZ]*)/g; 40 | var match, firstX, firstY, prevX, prevY; 41 | while ((match = segmentRE.exec(pathString))) { 42 | var args = match[2] 43 | .replace(/^\s*|\s*$/g, '') 44 | .split(/[,\s]+/) 45 | .map(function (v) { return parseFloat(v); }); 46 | switch (match[1]) { 47 | case 'M': 48 | prevX = firstX = args[0]; 49 | prevY = firstY = args[1]; 50 | break 51 | case 'L': 52 | if (args[0] !== prevX || args[1] !== prevY) { // yup, some fonts have zero-length line commands 53 | commandCallback('L', prevX, prevY, (prevX = args[0]), (prevY = args[1])); 54 | } 55 | break 56 | case 'Q': { 57 | commandCallback('Q', prevX, prevY, (prevX = args[2]), (prevY = args[3]), args[0], args[1]); 58 | break 59 | } 60 | case 'C': { 61 | commandCallback('C', prevX, prevY, (prevX = args[4]), (prevY = args[5]), args[0], args[1], args[2], args[3]); 62 | break 63 | } 64 | case 'Z': 65 | if (prevX !== firstX || prevY !== firstY) { 66 | commandCallback('L', prevX, prevY, firstX, firstY); 67 | } 68 | break 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * Convert a path string to a series of straight line segments 75 | * @param {string} pathString - An SVG-like path string to parse; should only contain commands: M/L/Q/C/Z 76 | * @param {function(x1:number, y1:number, x2:number, y2:number)} segmentCallback - A callback 77 | * function that will be called once for every line segment 78 | * @param {number} [curvePoints] - How many straight line segments to use when approximating a 79 | * bezier curve in the path. Defaults to 16. 80 | */ 81 | function pathToLineSegments (pathString, segmentCallback, curvePoints) { 82 | if ( curvePoints === void 0 ) curvePoints = 16; 83 | 84 | var tempPoint = { x: 0, y: 0 }; 85 | forEachPathCommand(pathString, function (command, startX, startY, endX, endY, ctrl1X, ctrl1Y, ctrl2X, ctrl2Y) { 86 | switch (command) { 87 | case 'L': 88 | segmentCallback(startX, startY, endX, endY); 89 | break 90 | case 'Q': { 91 | var prevCurveX = startX; 92 | var prevCurveY = startY; 93 | for (var i = 1; i < curvePoints; i++) { 94 | pointOnQuadraticBezier( 95 | startX, startY, 96 | ctrl1X, ctrl1Y, 97 | endX, endY, 98 | i / (curvePoints - 1), 99 | tempPoint 100 | ); 101 | segmentCallback(prevCurveX, prevCurveY, tempPoint.x, tempPoint.y); 102 | prevCurveX = tempPoint.x; 103 | prevCurveY = tempPoint.y; 104 | } 105 | break 106 | } 107 | case 'C': { 108 | var prevCurveX$1 = startX; 109 | var prevCurveY$1 = startY; 110 | for (var i$1 = 1; i$1 < curvePoints; i$1++) { 111 | pointOnCubicBezier( 112 | startX, startY, 113 | ctrl1X, ctrl1Y, 114 | ctrl2X, ctrl2Y, 115 | endX, endY, 116 | i$1 / (curvePoints - 1), 117 | tempPoint 118 | ); 119 | segmentCallback(prevCurveX$1, prevCurveY$1, tempPoint.x, tempPoint.y); 120 | prevCurveX$1 = tempPoint.x; 121 | prevCurveY$1 = tempPoint.y; 122 | } 123 | break 124 | } 125 | } 126 | }); 127 | } 128 | 129 | var viewportQuadVertex = "precision highp float;attribute vec2 aUV;varying vec2 vUV;void main(){vUV=aUV;gl_Position=vec4(mix(vec2(-1.0),vec2(1.0),aUV),0.0,1.0);}"; 130 | 131 | var copyTexFragment = "precision highp float;uniform sampler2D tex;varying vec2 vUV;void main(){gl_FragColor=texture2D(tex,vUV);}"; 132 | 133 | var cache = new WeakMap(); 134 | 135 | var glContextParams = { 136 | premultipliedAlpha: false, 137 | preserveDrawingBuffer: true, 138 | antialias: false, 139 | depth: false, 140 | }; 141 | 142 | /** 143 | * This is a little helper library for WebGL. It assists with state management for a GL context. 144 | * It's pretty tightly wrapped to the needs of this package, not very general-purpose. 145 | * 146 | * @param { WebGLRenderingContext | HTMLCanvasElement | OffscreenCanvas } glOrCanvas - the GL context to wrap 147 | * @param { ({gl, getExtension, withProgram, withTexture, withTextureFramebuffer, handleContextLoss}) => void } callback 148 | */ 149 | function withWebGLContext (glOrCanvas, callback) { 150 | var gl = glOrCanvas.getContext ? glOrCanvas.getContext('webgl', glContextParams) : glOrCanvas; 151 | var wrapper = cache.get(gl); 152 | if (!wrapper) { 153 | var isWebGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext; 154 | var extensions = {}; 155 | var programs = {}; 156 | var textures = {}; 157 | var textureUnit = -1; 158 | var framebufferStack = []; 159 | 160 | gl.canvas.addEventListener('webglcontextlost', function (e) { 161 | handleContextLoss(); 162 | e.preventDefault(); 163 | }, false); 164 | 165 | function getExtension (name) { 166 | var ext = extensions[name]; 167 | if (!ext) { 168 | ext = extensions[name] = gl.getExtension(name); 169 | if (!ext) { 170 | throw new Error((name + " not supported")) 171 | } 172 | } 173 | return ext 174 | } 175 | 176 | function compileShader (src, type) { 177 | var shader = gl.createShader(type); 178 | gl.shaderSource(shader, src); 179 | gl.compileShader(shader); 180 | // const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS) 181 | // if (!status && !gl.isContextLost()) { 182 | // throw new Error(gl.getShaderInfoLog(shader).trim()) 183 | // } 184 | return shader 185 | } 186 | 187 | function withProgram (name, vert, frag, func) { 188 | if (!programs[name]) { 189 | var attributes = {}; 190 | var uniforms = {}; 191 | var program = gl.createProgram(); 192 | gl.attachShader(program, compileShader(vert, gl.VERTEX_SHADER)); 193 | gl.attachShader(program, compileShader(frag, gl.FRAGMENT_SHADER)); 194 | gl.linkProgram(program); 195 | 196 | programs[name] = { 197 | program: program, 198 | transaction: function transaction (func) { 199 | gl.useProgram(program); 200 | func({ 201 | setUniform: function setUniform (type, name) { 202 | var values = [], len = arguments.length - 2; 203 | while ( len-- > 0 ) values[ len ] = arguments[ len + 2 ]; 204 | 205 | var uniformLoc = uniforms[name] || (uniforms[name] = gl.getUniformLocation(program, name)); 206 | gl[("uniform" + type)].apply(gl, [ uniformLoc ].concat( values )); 207 | }, 208 | 209 | setAttribute: function setAttribute (name, size, usage, instancingDivisor, data) { 210 | var attr = attributes[name]; 211 | if (!attr) { 212 | attr = attributes[name] = { 213 | buf: gl.createBuffer(), // TODO should we destroy our buffers? 214 | loc: gl.getAttribLocation(program, name), 215 | data: null 216 | }; 217 | } 218 | gl.bindBuffer(gl.ARRAY_BUFFER, attr.buf); 219 | gl.vertexAttribPointer(attr.loc, size, gl.FLOAT, false, 0, 0); 220 | gl.enableVertexAttribArray(attr.loc); 221 | if (isWebGL2) { 222 | gl.vertexAttribDivisor(attr.loc, instancingDivisor); 223 | } else { 224 | getExtension('ANGLE_instanced_arrays').vertexAttribDivisorANGLE(attr.loc, instancingDivisor); 225 | } 226 | if (data !== attr.data) { 227 | gl.bufferData(gl.ARRAY_BUFFER, data, usage); 228 | attr.data = data; 229 | } 230 | } 231 | }); 232 | } 233 | }; 234 | } 235 | 236 | programs[name].transaction(func); 237 | } 238 | 239 | function withTexture (name, func) { 240 | textureUnit++; 241 | try { 242 | gl.activeTexture(gl.TEXTURE0 + textureUnit); 243 | var texture = textures[name]; 244 | if (!texture) { 245 | texture = textures[name] = gl.createTexture(); 246 | gl.bindTexture(gl.TEXTURE_2D, texture); 247 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 248 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 249 | } 250 | gl.bindTexture(gl.TEXTURE_2D, texture); 251 | func(texture, textureUnit); 252 | } finally { 253 | textureUnit--; 254 | } 255 | } 256 | 257 | function withTextureFramebuffer (texture, textureUnit, func) { 258 | var framebuffer = gl.createFramebuffer(); 259 | framebufferStack.push(framebuffer); 260 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); 261 | gl.activeTexture(gl.TEXTURE0 + textureUnit); 262 | gl.bindTexture(gl.TEXTURE_2D, texture); 263 | gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); 264 | try { 265 | func(framebuffer); 266 | } finally { 267 | gl.deleteFramebuffer(framebuffer); 268 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferStack[--framebufferStack.length - 1] || null); 269 | } 270 | } 271 | 272 | function handleContextLoss () { 273 | extensions = {}; 274 | programs = {}; 275 | textures = {}; 276 | textureUnit = -1; 277 | framebufferStack.length = 0; 278 | } 279 | 280 | cache.set(gl, wrapper = { 281 | gl: gl, 282 | isWebGL2: isWebGL2, 283 | getExtension: getExtension, 284 | withProgram: withProgram, 285 | withTexture: withTexture, 286 | withTextureFramebuffer: withTextureFramebuffer, 287 | handleContextLoss: handleContextLoss, 288 | }); 289 | } 290 | callback(wrapper); 291 | } 292 | 293 | 294 | function renderImageData(glOrCanvas, imageData, x, y, width, height, channels, framebuffer) { 295 | if ( channels === void 0 ) channels = 15; 296 | if ( framebuffer === void 0 ) framebuffer = null; 297 | 298 | withWebGLContext(glOrCanvas, function (ref) { 299 | var gl = ref.gl; 300 | var withProgram = ref.withProgram; 301 | var withTexture = ref.withTexture; 302 | 303 | withTexture('copy', function (tex, texUnit) { 304 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, imageData); 305 | withProgram('copy', viewportQuadVertex, copyTexFragment, function (ref) { 306 | var setUniform = ref.setUniform; 307 | var setAttribute = ref.setAttribute; 308 | 309 | setAttribute('aUV', 2, gl.STATIC_DRAW, 0, new Float32Array([0, 0, 2, 0, 0, 2])); 310 | setUniform('1i', 'image', texUnit); 311 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer || null); 312 | gl.disable(gl.BLEND); 313 | gl.colorMask(channels & 8, channels & 4, channels & 2, channels & 1); 314 | gl.viewport(x, y, width, height); 315 | gl.scissor(x, y, width, height); 316 | gl.drawArrays(gl.TRIANGLES, 0, 3); 317 | }); 318 | }); 319 | }); 320 | } 321 | 322 | /** 323 | * Resizing a canvas clears its contents; this utility copies the previous contents over. 324 | * @param canvas 325 | * @param newWidth 326 | * @param newHeight 327 | */ 328 | function resizeWebGLCanvasWithoutClearing(canvas, newWidth, newHeight) { 329 | var width = canvas.width; 330 | var height = canvas.height; 331 | withWebGLContext(canvas, function (ref) { 332 | var gl = ref.gl; 333 | 334 | var data = new Uint8Array(width * height * 4); 335 | gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); 336 | canvas.width = newWidth; 337 | canvas.height = newHeight; 338 | renderImageData(gl, data, 0, 0, width, height); 339 | }); 340 | } 341 | 342 | var webglUtils = /*#__PURE__*/Object.freeze({ 343 | __proto__: null, 344 | withWebGLContext: withWebGLContext, 345 | renderImageData: renderImageData, 346 | resizeWebGLCanvasWithoutClearing: resizeWebGLCanvasWithoutClearing 347 | }); 348 | 349 | function generate$2 (sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent) { 350 | if ( sdfExponent === void 0 ) sdfExponent = 1; 351 | 352 | var textureData = new Uint8Array(sdfWidth * sdfHeight); 353 | 354 | var viewBoxWidth = viewBox[2] - viewBox[0]; 355 | var viewBoxHeight = viewBox[3] - viewBox[1]; 356 | 357 | // Decompose all paths into straight line segments and add them to an index 358 | var segments = []; 359 | pathToLineSegments(path, function (x1, y1, x2, y2) { 360 | segments.push({ 361 | x1: x1, y1: y1, x2: x2, y2: y2, 362 | minX: Math.min(x1, x2), 363 | minY: Math.min(y1, y2), 364 | maxX: Math.max(x1, x2), 365 | maxY: Math.max(y1, y2) 366 | }); 367 | }); 368 | 369 | // Sort segments by maxX, this will let us short-circuit some loops below 370 | segments.sort(function (a, b) { return a.maxX - b.maxX; }); 371 | 372 | // For each target SDF texel, find the distance from its center to its nearest line segment, 373 | // map that distance to an alpha value, and write that alpha to the texel 374 | for (var sdfX = 0; sdfX < sdfWidth; sdfX++) { 375 | for (var sdfY = 0; sdfY < sdfHeight; sdfY++) { 376 | var signedDist = findNearestSignedDistance( 377 | viewBox[0] + viewBoxWidth * (sdfX + 0.5) / sdfWidth, 378 | viewBox[1] + viewBoxHeight * (sdfY + 0.5) / sdfHeight 379 | ); 380 | 381 | // Use an exponential scale to ensure the texels very near the glyph path have adequate 382 | // precision, while allowing the distance field to cover the entire texture, given that 383 | // there are only 8 bits available. Formula visualized: https://www.desmos.com/calculator/uiaq5aqiam 384 | var alpha = Math.pow((1 - Math.abs(signedDist) / maxDistance), sdfExponent) / 2; 385 | if (signedDist < 0) { 386 | alpha = 1 - alpha; 387 | } 388 | 389 | alpha = Math.max(0, Math.min(255, Math.round(alpha * 255))); //clamp 390 | textureData[sdfY * sdfWidth + sdfX] = alpha; 391 | } 392 | } 393 | 394 | return textureData 395 | 396 | /** 397 | * For a given x/y, search the index for the closest line segment and return 398 | * its signed distance. Negative = inside, positive = outside, zero = on edge 399 | * @param x 400 | * @param y 401 | * @returns {number} 402 | */ 403 | function findNearestSignedDistance (x, y) { 404 | var closestDistSq = Infinity; 405 | var closestDist = Infinity; 406 | 407 | for (var i = segments.length; i--;) { 408 | var seg = segments[i]; 409 | if (seg.maxX + closestDist <= x) { break } //sorting by maxX means no more can be closer, so we can short-circuit 410 | if (x + closestDist > seg.minX && y - closestDist < seg.maxY && y + closestDist > seg.minY) { 411 | var distSq = absSquareDistanceToLineSegment(x, y, seg.x1, seg.y1, seg.x2, seg.y2); 412 | if (distSq < closestDistSq) { 413 | closestDistSq = distSq; 414 | closestDist = Math.sqrt(closestDistSq); 415 | } 416 | } 417 | } 418 | 419 | // Flip to negative distance if inside the poly 420 | if (isPointInPoly(x, y)) { 421 | closestDist = -closestDist; 422 | } 423 | return closestDist 424 | } 425 | 426 | /** 427 | * Determine whether the given point lies inside or outside the glyph. Uses a simple 428 | * winding-number ray casting algorithm using a ray pointing east from the point. 429 | */ 430 | function isPointInPoly (x, y) { 431 | var winding = 0; 432 | for (var i = segments.length; i--;) { 433 | var seg = segments[i]; 434 | if (seg.maxX <= x) { break } //sorting by maxX means no more can cross, so we can short-circuit 435 | var intersects = ((seg.y1 > y) !== (seg.y2 > y)) && (x < (seg.x2 - seg.x1) * (y - seg.y1) / (seg.y2 - seg.y1) + seg.x1); 436 | if (intersects) { 437 | winding += seg.y1 < seg.y2 ? 1 : -1; 438 | } 439 | } 440 | return winding !== 0 441 | } 442 | } 443 | 444 | function generateIntoCanvas$2(sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, canvas, x, y, channel) { 445 | if ( sdfExponent === void 0 ) sdfExponent = 1; 446 | if ( x === void 0 ) x = 0; 447 | if ( y === void 0 ) y = 0; 448 | if ( channel === void 0 ) channel = 0; 449 | 450 | generateIntoFramebuffer$1(sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, canvas, null, x, y, channel); 451 | } 452 | 453 | function generateIntoFramebuffer$1 (sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, glOrCanvas, framebuffer, x, y, channel) { 454 | if ( sdfExponent === void 0 ) sdfExponent = 1; 455 | if ( x === void 0 ) x = 0; 456 | if ( y === void 0 ) y = 0; 457 | if ( channel === void 0 ) channel = 0; 458 | 459 | var data = generate$2(sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent); 460 | // Expand single-channel data to rbga 461 | var rgbaData = new Uint8Array(data.length * 4); 462 | for (var i = 0; i < data.length; i++) { 463 | rgbaData[i * 4 + channel] = data[i]; 464 | } 465 | renderImageData(glOrCanvas, rgbaData, x, y, sdfWidth, sdfHeight, 1 << (3 - channel), framebuffer); 466 | } 467 | 468 | /** 469 | * Find the absolute distance from a point to a line segment at closest approach 470 | */ 471 | function absSquareDistanceToLineSegment (x, y, lineX0, lineY0, lineX1, lineY1) { 472 | var ldx = lineX1 - lineX0; 473 | var ldy = lineY1 - lineY0; 474 | var lengthSq = ldx * ldx + ldy * ldy; 475 | var t = lengthSq ? Math.max(0, Math.min(1, ((x - lineX0) * ldx + (y - lineY0) * ldy) / lengthSq)) : 0; 476 | var dx = x - (lineX0 + t * ldx); 477 | var dy = y - (lineY0 + t * ldy); 478 | return dx * dx + dy * dy 479 | } 480 | 481 | var javascript = /*#__PURE__*/Object.freeze({ 482 | __proto__: null, 483 | generate: generate$2, 484 | generateIntoCanvas: generateIntoCanvas$2, 485 | generateIntoFramebuffer: generateIntoFramebuffer$1 486 | }); 487 | 488 | var mainVertex = "precision highp float;uniform vec4 uGlyphBounds;attribute vec2 aUV;attribute vec4 aLineSegment;varying vec4 vLineSegment;varying vec2 vGlyphXY;void main(){vLineSegment=aLineSegment;vGlyphXY=mix(uGlyphBounds.xy,uGlyphBounds.zw,aUV);gl_Position=vec4(mix(vec2(-1.0),vec2(1.0),aUV),0.0,1.0);}"; 489 | 490 | var mainFragment = "precision highp float;uniform vec4 uGlyphBounds;uniform float uMaxDistance;uniform float uExponent;varying vec4 vLineSegment;varying vec2 vGlyphXY;float absDistToSegment(vec2 point,vec2 lineA,vec2 lineB){vec2 lineDir=lineB-lineA;float lenSq=dot(lineDir,lineDir);float t=lenSq==0.0 ? 0.0 : clamp(dot(point-lineA,lineDir)/lenSq,0.0,1.0);vec2 linePt=lineA+t*lineDir;return distance(point,linePt);}void main(){vec4 seg=vLineSegment;vec2 p=vGlyphXY;float dist=absDistToSegment(p,seg.xy,seg.zw);float val=pow(1.0-clamp(dist/uMaxDistance,0.0,1.0),uExponent)*0.5;bool crossing=(seg.y>p.y!=seg.w>p.y)&&(p.x<(seg.z-seg.x)*(p.y-seg.y)/(seg.w-seg.y)+seg.x);bool crossingUp=crossing&&vLineSegment.y bool 501 | 502 | function validateSupport (glOrCanvas) { 503 | if (!isTestingSupport && !isSupported(glOrCanvas)) { 504 | throw new Error('WebGL generation not supported') 505 | } 506 | } 507 | 508 | function generate$1 (sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, glOrCanvas) { 509 | if ( sdfExponent === void 0 ) sdfExponent = 1; 510 | if ( glOrCanvas === void 0 ) glOrCanvas = null; 511 | 512 | if (!glOrCanvas) { 513 | glOrCanvas = implicitContext; 514 | if (!glOrCanvas) { 515 | var canvas = typeof OffscreenCanvas === 'function' 516 | ? new OffscreenCanvas(1, 1) 517 | : typeof document !== 'undefined' 518 | ? document.createElement('canvas') 519 | : null; 520 | if (!canvas) { 521 | throw new Error('OffscreenCanvas or DOM canvas not supported') 522 | } 523 | glOrCanvas = implicitContext = canvas.getContext('webgl', { depth: false }); 524 | } 525 | } 526 | 527 | validateSupport(glOrCanvas); 528 | 529 | var rgbaData = new Uint8Array(sdfWidth * sdfHeight * 4); //not Uint8ClampedArray, cuz Safari 530 | 531 | // Render into a background texture framebuffer 532 | withWebGLContext(glOrCanvas, function (ref) { 533 | var gl = ref.gl; 534 | var withTexture = ref.withTexture; 535 | var withTextureFramebuffer = ref.withTextureFramebuffer; 536 | 537 | withTexture('readable', function (texture, textureUnit) { 538 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, sdfWidth, sdfHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); 539 | 540 | withTextureFramebuffer(texture, textureUnit, function (framebuffer) { 541 | generateIntoFramebuffer( 542 | sdfWidth, 543 | sdfHeight, 544 | path, 545 | viewBox, 546 | maxDistance, 547 | sdfExponent, 548 | gl, 549 | framebuffer, 550 | 0, 551 | 0, 552 | 0 // red channel 553 | ); 554 | gl.readPixels(0, 0, sdfWidth, sdfHeight, gl.RGBA, gl.UNSIGNED_BYTE, rgbaData); 555 | }); 556 | }); 557 | }); 558 | 559 | // Throw away all but the red channel 560 | var data = new Uint8Array(sdfWidth * sdfHeight); 561 | for (var i = 0, j = 0; i < rgbaData.length; i += 4) { 562 | data[j++] = rgbaData[i]; 563 | } 564 | 565 | return data 566 | } 567 | 568 | function generateIntoCanvas$1(sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, canvas, x, y, channel) { 569 | if ( sdfExponent === void 0 ) sdfExponent = 1; 570 | if ( x === void 0 ) x = 0; 571 | if ( y === void 0 ) y = 0; 572 | if ( channel === void 0 ) channel = 0; 573 | 574 | generateIntoFramebuffer(sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, canvas, null, x, y, channel); 575 | } 576 | 577 | function generateIntoFramebuffer (sdfWidth, sdfHeight, path, viewBox, maxDistance, sdfExponent, glOrCanvas, framebuffer, x, y, channel) { 578 | if ( sdfExponent === void 0 ) sdfExponent = 1; 579 | if ( x === void 0 ) x = 0; 580 | if ( y === void 0 ) y = 0; 581 | if ( channel === void 0 ) channel = 0; 582 | 583 | // Verify support 584 | validateSupport(glOrCanvas); 585 | 586 | // Compute path segments 587 | var lineSegmentCoords = []; 588 | pathToLineSegments(path, function (x1, y1, x2, y2) { 589 | lineSegmentCoords.push(x1, y1, x2, y2); 590 | }); 591 | lineSegmentCoords = new Float32Array(lineSegmentCoords); 592 | 593 | withWebGLContext(glOrCanvas, function (ref) { 594 | var gl = ref.gl; 595 | var isWebGL2 = ref.isWebGL2; 596 | var getExtension = ref.getExtension; 597 | var withProgram = ref.withProgram; 598 | var withTexture = ref.withTexture; 599 | var withTextureFramebuffer = ref.withTextureFramebuffer; 600 | var handleContextLoss = ref.handleContextLoss; 601 | 602 | withTexture('rawDistances', function (intermediateTexture, intermediateTextureUnit) { 603 | if (sdfWidth !== intermediateTexture._lastWidth || sdfHeight !== intermediateTexture._lastHeight) { 604 | gl.texImage2D( 605 | gl.TEXTURE_2D, 0, gl.RGBA, 606 | intermediateTexture._lastWidth = sdfWidth, 607 | intermediateTexture._lastHeight = sdfHeight, 608 | 0, gl.RGBA, gl.UNSIGNED_BYTE, null 609 | ); 610 | } 611 | 612 | // Unsigned distance pass 613 | withProgram('main', mainVertex, mainFragment, function (ref) { 614 | var setAttribute = ref.setAttribute; 615 | var setUniform = ref.setUniform; 616 | 617 | // Init extensions 618 | var instancingExtension = !isWebGL2 && getExtension('ANGLE_instanced_arrays'); 619 | var blendMinMaxExtension = !isWebGL2 && getExtension('EXT_blend_minmax'); 620 | 621 | // Init/update attributes 622 | setAttribute('aUV', 2, gl.STATIC_DRAW, 0, viewportUVs); 623 | setAttribute('aLineSegment', 4, gl.DYNAMIC_DRAW, 1, lineSegmentCoords); 624 | 625 | // Init/update uniforms 626 | setUniform.apply(void 0, [ '4f', 'uGlyphBounds' ].concat( viewBox )); 627 | setUniform('1f', 'uMaxDistance', maxDistance); 628 | setUniform('1f', 'uExponent', sdfExponent); 629 | 630 | // Render initial unsigned distance / winding number info to a texture 631 | withTextureFramebuffer(intermediateTexture, intermediateTextureUnit, function (framebuffer) { 632 | gl.enable(gl.BLEND); 633 | gl.colorMask(true, true, true, true); 634 | gl.viewport(0, 0, sdfWidth, sdfHeight); 635 | gl.scissor(0, 0, sdfWidth, sdfHeight); 636 | gl.blendFunc(gl.ONE, gl.ONE); 637 | // Red+Green channels are incremented (FUNC_ADD) for segment-ray crossings to give a "winding number". 638 | // Alpha holds the closest (MAX) unsigned distance. 639 | gl.blendEquationSeparate(gl.FUNC_ADD, isWebGL2 ? gl.MAX : blendMinMaxExtension.MAX_EXT); 640 | gl.clear(gl.COLOR_BUFFER_BIT); 641 | if (isWebGL2) { 642 | gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, lineSegmentCoords.length / 4); 643 | } else { 644 | instancingExtension.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 3, lineSegmentCoords.length / 4); 645 | } 646 | // Debug 647 | // const debug = new Uint8Array(sdfWidth * sdfHeight * 4) 648 | // gl.readPixels(0, 0, sdfWidth, sdfHeight, gl.RGBA, gl.UNSIGNED_BYTE, debug) 649 | // console.log('intermediate texture data: ', debug) 650 | }); 651 | }); 652 | 653 | // Use the data stored in the texture to apply inside/outside and write to the output framebuffer rect+channel. 654 | withProgram('post', viewportQuadVertex, postFragment, function (program) { 655 | program.setAttribute('aUV', 2, gl.STATIC_DRAW, 0, viewportUVs); 656 | program.setUniform('1i', 'tex', intermediateTextureUnit); 657 | gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); 658 | gl.disable(gl.BLEND); 659 | gl.colorMask(channel === 0, channel === 1, channel === 2, channel === 3); 660 | gl.viewport(x, y, sdfWidth, sdfHeight); 661 | gl.scissor(x, y, sdfWidth, sdfHeight); 662 | gl.drawArrays(gl.TRIANGLES, 0, 3); 663 | }); 664 | }); 665 | 666 | // Handle context loss occurring during any of the above calls 667 | if (gl.isContextLost()) { 668 | handleContextLoss(); 669 | throw new Error('webgl context lost') 670 | } 671 | }); 672 | } 673 | 674 | function isSupported (glOrCanvas) { 675 | var key = (!glOrCanvas || glOrCanvas === implicitContext) ? NULL_OBJECT : (glOrCanvas.canvas || glOrCanvas); 676 | var supported = supportByCanvas.get(key); 677 | if (supported === undefined) { 678 | isTestingSupport = true; 679 | var failReason = null; 680 | try { 681 | // Since we can't detect all failure modes up front, let's just do a trial run of a 682 | // simple path and compare what we get back to the correct expected result. This will 683 | // also serve to prime the shader compilation. 684 | var expectedResult = [ 685 | 97, 106, 97, 61, 686 | 99, 137, 118, 80, 687 | 80, 118, 137, 99, 688 | 61, 97, 106, 97 689 | ]; 690 | var testResult = generate$1( 691 | 4, 692 | 4, 693 | 'M8,8L16,8L24,24L16,24Z', 694 | [0, 0, 32, 32], 695 | 24, 696 | 1, 697 | glOrCanvas 698 | ); 699 | supported = testResult && expectedResult.length === testResult.length && 700 | testResult.every(function (val, i) { return val === expectedResult[i]; }); 701 | if (!supported) { 702 | failReason = 'bad trial run results'; 703 | console.info(expectedResult, testResult); 704 | } 705 | } catch (err) { 706 | // TODO if it threw due to webgl context loss, should we maybe leave isSupported as null and try again later? 707 | supported = false; 708 | failReason = err.message; 709 | } 710 | if (failReason) { 711 | console.warn('WebGL SDF generation not supported:', failReason); 712 | } 713 | isTestingSupport = false; 714 | supportByCanvas.set(key, supported); 715 | } 716 | return supported 717 | } 718 | 719 | var webgl = /*#__PURE__*/Object.freeze({ 720 | __proto__: null, 721 | generate: generate$1, 722 | generateIntoCanvas: generateIntoCanvas$1, 723 | generateIntoFramebuffer: generateIntoFramebuffer, 724 | isSupported: isSupported 725 | }); 726 | 727 | /** 728 | * Generate an SDF texture image for a 2D path. 729 | * 730 | * @param {number} sdfWidth - width of the SDF output image in pixels. 731 | * @param {number} sdfHeight - height of the SDF output image in pixels. 732 | * @param {string} path - an SVG-like path string describing the glyph; should only contain commands: M/L/Q/C/Z. 733 | * @param {number[]} viewBox - [minX, minY, maxX, maxY] in font units aligning with the texture's edges. 734 | * @param {number} maxDistance - the maximum distance from the glyph path in font units that will be encoded; defaults 735 | * to half the maximum viewBox dimension. 736 | * @param {number} [sdfExponent] - specifies an exponent for encoding the SDF's distance values; higher exponents 737 | * will give greater precision nearer the glyph's path. 738 | * @return {Uint8Array} 739 | */ 740 | function generate( 741 | sdfWidth, 742 | sdfHeight, 743 | path, 744 | viewBox, 745 | maxDistance, 746 | sdfExponent 747 | ) { 748 | if ( maxDistance === void 0 ) maxDistance = Math.max(viewBox[2] - viewBox[0], viewBox[3] - viewBox[1]) / 2; 749 | if ( sdfExponent === void 0 ) sdfExponent = 1; 750 | 751 | try { 752 | return generate$1.apply(webgl, arguments) 753 | } catch(e) { 754 | console.info('WebGL SDF generation failed, falling back to JS', e); 755 | return generate$2.apply(javascript, arguments) 756 | } 757 | } 758 | 759 | /** 760 | * Generate an SDF texture image for a 2D path, inserting the result into a WebGL `canvas` at a given x/y position 761 | * and color channel. This is generally much faster than calling `generate` because it does not require reading pixels 762 | * back from the GPU->CPU -- the `canvas` can be used directly as a WebGL texture image, so it all stays on the GPU. 763 | * 764 | * @param {number} sdfWidth - width of the SDF output image in pixels. 765 | * @param {number} sdfHeight - height of the SDF output image in pixels. 766 | * @param {string} path - an SVG-like path string describing the glyph; should only contain commands: M/L/Q/C/Z. 767 | * @param {number[]} viewBox - [minX, minY, maxX, maxY] in font units aligning with the texture's edges. 768 | * @param {number} maxDistance - the maximum distance from the glyph path in font units that will be encoded; defaults 769 | * to half the maximum viewBox dimension. 770 | * @param {number} [sdfExponent] - specifies an exponent for encoding the SDF's distance values; higher exponents 771 | * will give greater precision nearer the glyph's path. 772 | * @param {HTMLCanvasElement|OffscreenCanvas} canvas - a WebGL-enabled canvas into which the SDF will be rendered. 773 | * Only the relevant rect/channel will be modified, the rest will be preserved. To avoid unpredictable results 774 | * due to shared GL context state, this canvas should be dedicated to use by this library alone. 775 | * @param {number} x - the x position at which to render the SDF. 776 | * @param {number} y - the y position at which to render the SDF. 777 | * @param {number} channel - the color channel index (0-4) into which the SDF will be rendered. 778 | * @return {Uint8Array} 779 | */ 780 | function generateIntoCanvas( 781 | sdfWidth, 782 | sdfHeight, 783 | path, 784 | viewBox, 785 | maxDistance, 786 | sdfExponent, 787 | canvas, 788 | x, 789 | y, 790 | channel 791 | ) { 792 | if ( maxDistance === void 0 ) maxDistance = Math.max(viewBox[2] - viewBox[0], viewBox[3] - viewBox[1]) / 2; 793 | if ( sdfExponent === void 0 ) sdfExponent = 1; 794 | if ( x === void 0 ) x = 0; 795 | if ( y === void 0 ) y = 0; 796 | if ( channel === void 0 ) channel = 0; 797 | 798 | try { 799 | return generateIntoCanvas$1.apply(webgl, arguments) 800 | } catch(e) { 801 | console.info('WebGL SDF generation failed, falling back to JS', e); 802 | return generateIntoCanvas$2.apply(javascript, arguments) 803 | } 804 | } 805 | 806 | exports.forEachPathCommand = forEachPathCommand; 807 | exports.generate = generate; 808 | exports.generateIntoCanvas = generateIntoCanvas; 809 | exports.javascript = javascript; 810 | exports.pathToLineSegments = pathToLineSegments; 811 | exports.webgl = webgl; 812 | exports.webglUtils = webglUtils; 813 | 814 | Object.defineProperty(exports, '__esModule', { value: true }); 815 | 816 | return exports; 817 | 818 | }({})); 819 | return exports 820 | } 821 | 822 | export { SDFGenerator as default }; 823 | -------------------------------------------------------------------------------- /example/webxr-button.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This is a stripped down and specialized version of WebVR-UI 16 | // (https://github.com/googlevr/webvr-ui) that takes out most of the state 17 | // management in favor of providing a simple way of requesting entry into WebXR 18 | // for the needs of the sample pages. Functionality like beginning sessions 19 | // is intentionally left out so that the sample pages can demonstrate them more 20 | // clearly. 21 | 22 | // 23 | // State consts 24 | // 25 | 26 | // Not yet presenting, but ready to present 27 | const READY_TO_PRESENT = 'ready'; 28 | 29 | // In presentation mode 30 | const PRESENTING = 'presenting'; 31 | const PRESENTING_FULLSCREEN = 'presenting-fullscreen'; 32 | 33 | // Checking device availability 34 | const PREPARING = 'preparing'; 35 | 36 | // Errors 37 | const ERROR_NO_PRESENTABLE_DISPLAYS = 'error-no-presentable-displays'; 38 | const ERROR_BROWSER_NOT_SUPPORTED = 'error-browser-not-supported'; 39 | const ERROR_REQUEST_TO_PRESENT_REJECTED = 'error-request-to-present-rejected'; 40 | const ERROR_EXIT_PRESENT_REJECTED = 'error-exit-present-rejected'; 41 | const ERROR_REQUEST_STATE_CHANGE_REJECTED = 'error-request-state-change-rejected'; 42 | const ERROR_UNKOWN = 'error-unkown'; 43 | 44 | // 45 | // DOM element 46 | // 47 | 48 | const _LOGO_SCALE = 0.8; 49 | let _WEBXR_UI_CSS_INJECTED = {}; 50 | 51 | /** 52 | * Generate the innerHTML for the button 53 | * 54 | * @return {string} html of the button as string 55 | * @param {string} cssPrefix 56 | * @param {Number} height 57 | * @private 58 | */ 59 | const generateInnerHTML = (cssPrefix, height)=> { 60 | const logoHeight = height*_LOGO_SCALE; 61 | const svgString = generateXRIconString(cssPrefix, logoHeight) + generateNoXRIconString(cssPrefix, logoHeight); 62 | 63 | return ``; 67 | }; 68 | 69 | /** 70 | * Inject the CSS string to the head of the document 71 | * 72 | * @param {string} cssText the css to inject 73 | */ 74 | const injectCSS = (cssText)=> { 75 | // Create the css 76 | const style = document.createElement('style'); 77 | style.innerHTML = cssText; 78 | 79 | let head = document.getElementsByTagName('head')[0]; 80 | head.insertBefore(style, head.firstChild); 81 | }; 82 | 83 | /** 84 | * Generate DOM element view for button 85 | * 86 | * @return {HTMLElement} 87 | * @param {Object} options 88 | */ 89 | const createDefaultView = (options)=> { 90 | const fontSize = options.height / 3; 91 | if (options.injectCSS) { 92 | // Check that css isnt already injected 93 | if (!_WEBXR_UI_CSS_INJECTED[options.cssprefix]) { 94 | injectCSS(generateCSS(options, fontSize)); 95 | _WEBXR_UI_CSS_INJECTED[options.cssprefix] = true; 96 | } 97 | } 98 | 99 | const el = document.createElement('div'); 100 | el.innerHTML = generateInnerHTML(options.cssprefix, fontSize); 101 | return el.firstChild; 102 | }; 103 | 104 | 105 | const createXRIcon = (cssPrefix, height)=>{ 106 | const el = document.createElement('div'); 107 | el.innerHTML = generateXRIconString(cssPrefix, height); 108 | return el.firstChild; 109 | }; 110 | 111 | const createNoXRIcon = (cssPrefix, height)=>{ 112 | const el = document.createElement('div'); 113 | el.innerHTML = generateNoXRIconString(cssPrefix, height); 114 | return el.firstChild; 115 | }; 116 | 117 | const generateXRIconString = (cssPrefix, height)=> { 118 | let aspect = 28 / 18; 119 | return ` 121 | 128 | `; 129 | }; 130 | 131 | const generateNoXRIconString = (cssPrefix, height)=>{ 132 | let aspect = 28 / 18; 133 | return ` 135 | 139 | 142 | 144 | `; 145 | }; 146 | 147 | /** 148 | * Generate the CSS string to inject 149 | * 150 | * @param {Object} options 151 | * @param {Number} [fontSize=18] 152 | * @return {string} 153 | */ 154 | const generateCSS = (options, fontSize=18)=> { 155 | const height = options.height; 156 | const borderWidth = 2; 157 | const borderColor = options.background ? options.background : options.color; 158 | const cssPrefix = options.cssprefix; 159 | 160 | let borderRadius; 161 | if (options.corners == 'round') { 162 | borderRadius = options.height / 2; 163 | } else if (options.corners == 'square') { 164 | borderRadius = 2; 165 | } else { 166 | borderRadius = options.corners; 167 | } 168 | 169 | return (` 170 | @font-face { 171 | font-family: 'Karla'; 172 | font-style: normal; 173 | font-weight: 400; 174 | src: local('Karla'), local('Karla-Regular'), 175 | url(https://fonts.gstatic.com/s/karla/v5/31P4mP32i98D9CEnGyeX9Q.woff2) format('woff2'); 176 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 177 | } 178 | @font-face { 179 | font-family: 'Karla'; 180 | font-style: normal; 181 | font-weight: 400; 182 | src: local('Karla'), local('Karla-Regular'), 183 | url(https://fonts.gstatic.com/s/karla/v5/Zi_e6rBgGqv33BWF8WTq8g.woff2) format('woff2'); 184 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, 185 | U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 186 | } 187 | 188 | button.${cssPrefix}-button { 189 | font-family: 'Karla', sans-serif; 190 | 191 | border: ${borderColor} ${borderWidth}px solid; 192 | border-radius: ${borderRadius}px; 193 | box-sizing: border-box; 194 | background: ${options.background ? options.background : 'none'}; 195 | 196 | height: ${height}px; 197 | min-width: ${fontSize * 9.6}px; 198 | display: inline-block; 199 | position: relative; 200 | 201 | cursor: pointer; 202 | transition: border 0.5s; 203 | } 204 | 205 | button.${cssPrefix}-button:focus { 206 | outline: none; 207 | } 208 | 209 | /* 210 | * Logo 211 | */ 212 | 213 | .${cssPrefix}-logo { 214 | width: ${height}px; 215 | height: ${height}px; 216 | position: absolute; 217 | top:0px; 218 | left:0px; 219 | width: ${height - 4}px; 220 | height: ${height - 4}px; 221 | } 222 | .${cssPrefix}-svg { 223 | fill: ${options.color}; 224 | margin-top: ${(height - fontSize * _LOGO_SCALE) / 2 - 2}px; 225 | margin-left: ${height / 3 }px; 226 | } 227 | .${cssPrefix}-svg-error { 228 | fill: ${options.color}; 229 | display:none; 230 | margin-top: ${(height - 28 / 18 * fontSize * _LOGO_SCALE) / 2 - 2}px; 231 | margin-left: ${height / 3 }px; 232 | } 233 | 234 | 235 | /* 236 | * Title 237 | */ 238 | 239 | .${cssPrefix}-title { 240 | color: ${options.color}; 241 | position: relative; 242 | font-size: ${fontSize}px; 243 | padding-left: ${height * 1.05}px; 244 | padding-right: ${(borderRadius - 10 < 5) ? height / 3 : borderRadius - 10}px; 245 | transition: color 0.5s; 246 | } 247 | 248 | /* 249 | * disabled 250 | */ 251 | 252 | button.${cssPrefix}-button[disabled=true] { 253 | opacity: ${options.disabledOpacity}; 254 | } 255 | 256 | button.${cssPrefix}-button[disabled=true] > .${cssPrefix}-logo > .${cssPrefix}-svg { 257 | display:none; 258 | } 259 | 260 | button.${cssPrefix}-button[disabled=true] > .${cssPrefix}-logo > .${cssPrefix}-svg-error { 261 | display:initial; 262 | } 263 | 264 | /* 265 | * error 266 | */ 267 | 268 | button.${cssPrefix}-button[error=true] { 269 | animation: errorShake 0.4s; 270 | } 271 | 272 | @keyframes errorShake { 273 | 0% { transform: translate(1px, 0) } 274 | 10% { transform: translate(-2px, 0) } 275 | 20% { transform: translate(2px, 0) } 276 | 30% { transform: translate(-2px, 0) } 277 | 40% { transform: translate(2px, 0) } 278 | 50% { transform: translate(-2px, 0) } 279 | 60% { transform: translate(2px, 0) } 280 | 70% { transform: translate(-2px, 0) } 281 | 80% { transform: translate(2px, 0) } 282 | 90% { transform: translate(-1px, 0) } 283 | 100% { transform: translate(0px, 0) } 284 | } 285 | `); 286 | }; 287 | 288 | // 289 | // Button class 290 | // 291 | 292 | export class WebXRButton { 293 | /** 294 | * Construct a new Enter XR Button 295 | * @constructor 296 | * @param {HTMLCanvasElement} sourceCanvas the canvas that you want to present with WebXR 297 | * @param {Object} [options] optional parameters 298 | * @param {HTMLElement} [options.domElement] provide your own domElement to bind to 299 | * @param {Boolean} [options.injectCSS=true] set to false if you want to write your own styles 300 | * @param {Function} [options.beforeEnter] should return a promise, opportunity to intercept request to enter 301 | * @param {Function} [options.beforeExit] should return a promise, opportunity to intercept request to exit 302 | * @param {Function} [options.onRequestStateChange] set to a function returning false to prevent default state changes 303 | * @param {string} [options.textEnterXRTitle] set the text for Enter XR 304 | * @param {string} [options.textXRNotFoundTitle] set the text for when a XR display is not found 305 | * @param {string} [options.textExitXRTitle] set the text for exiting XR 306 | * @param {string} [options.color] text and icon color 307 | * @param {string} [options.background] set to false for no brackground or a color 308 | * @param {string} [options.corners] set to 'round', 'square' or pixel value representing the corner radius 309 | * @param {string} [options.disabledOpacity] set opacity of button dom when disabled 310 | * @param {string} [options.cssprefix] set to change the css prefix from default 'webvr-ui' 311 | */ 312 | constructor(options) { 313 | options = options || {}; 314 | 315 | options.color = options.color || 'rgb(80,168,252)'; 316 | options.background = options.background || false; 317 | options.disabledOpacity = options.disabledOpacity || 0.5; 318 | options.height = options.height || 55; 319 | options.corners = options.corners || 'square'; 320 | options.cssprefix = options.cssprefix || 'webvr-ui'; 321 | 322 | // This reads VR as none of the samples are designed for other formats as of yet. 323 | options.textEnterXRTitle = options.textEnterXRTitle || 'ENTER VR'; 324 | options.textXRNotFoundTitle = options.textXRNotFoundTitle || 'VR NOT FOUND'; 325 | options.textExitXRTitle = options.textExitXRTitle || 'EXIT VR'; 326 | 327 | options.onRequestSession = options.onRequestSession || (function() {}); 328 | options.onEndSession = options.onEndSession || (function() {}); 329 | 330 | options.injectCSS = options.injectCSS !== false; 331 | 332 | this.options = options; 333 | 334 | this._enabled = false; 335 | this.session = null; 336 | 337 | // Pass in your own domElement if you really dont want to use ours 338 | this.domElement = options.domElement || createDefaultView(options); 339 | this.__defaultDisplayStyle = this.domElement.style.display || 'initial'; 340 | 341 | // Bind button click events to __onClick 342 | this.domElement.addEventListener('click', ()=> this.__onXRButtonClick()); 343 | 344 | this.__forceDisabled = false; 345 | this.__setDisabledAttribute(true); 346 | this.setTitle(this.options.textXRNotFoundTitle); 347 | } 348 | 349 | /** 350 | * Sets the enabled state of this button. 351 | * @param {boolean} enabled 352 | */ 353 | set enabled(enabled) { 354 | this._enabled = enabled; 355 | this.__updateButtonState(); 356 | return this; 357 | } 358 | 359 | /** 360 | * Gets the enabled state of this button. 361 | * @return {boolean} 362 | */ 363 | get enabled() { 364 | return this._enabled; 365 | } 366 | 367 | /** 368 | * Indicate that there's an active XRSession. Switches the button to "Exit XR" 369 | * state if not null, or "Enter XR" state if null. 370 | * @param {XRSession} session 371 | * @return {EnterXRButton} 372 | */ 373 | setSession(session) { 374 | this.session = session; 375 | this.__updateButtonState(); 376 | return this; 377 | } 378 | 379 | /** 380 | * Set the title of the button 381 | * @param {string} text 382 | * @return {EnterXRButton} 383 | */ 384 | setTitle(text) { 385 | this.domElement.title = text; 386 | ifChild(this.domElement, this.options.cssprefix, 'title', (title)=> { 387 | if (!text) { 388 | title.style.display = 'none'; 389 | } else { 390 | title.innerText = text; 391 | title.style.display = 'initial'; 392 | } 393 | }); 394 | 395 | return this; 396 | } 397 | 398 | /** 399 | * Set the tooltip of the button 400 | * @param {string} tooltip 401 | * @return {EnterXRButton} 402 | */ 403 | setTooltip(tooltip) { 404 | this.domElement.title = tooltip; 405 | return this; 406 | } 407 | 408 | /** 409 | * Show the button 410 | * @return {EnterXRButton} 411 | */ 412 | show() { 413 | this.domElement.style.display = this.__defaultDisplayStyle; 414 | return this; 415 | } 416 | 417 | /** 418 | * Hide the button 419 | * @return {EnterXRButton} 420 | */ 421 | hide() { 422 | this.domElement.style.display = 'none'; 423 | return this; 424 | } 425 | 426 | /** 427 | * Enable the button 428 | * @return {EnterXRButton} 429 | */ 430 | enable() { 431 | this.__setDisabledAttribute(false); 432 | this.__forceDisabled = false; 433 | return this; 434 | } 435 | 436 | /** 437 | * Disable the button from being clicked 438 | * @return {EnterXRButton} 439 | */ 440 | disable() { 441 | this.__setDisabledAttribute(true); 442 | this.__forceDisabled = true; 443 | return this; 444 | } 445 | 446 | /** 447 | * clean up object for garbage collection 448 | */ 449 | remove() { 450 | if (this.domElement.parentElement) { 451 | this.domElement.parentElement.removeChild(this.domElement); 452 | } 453 | } 454 | 455 | /** 456 | * Set the disabled attribute 457 | * @param {boolean} disabled 458 | * @private 459 | */ 460 | __setDisabledAttribute(disabled) { 461 | if (disabled || this.__forceDisabled) { 462 | this.domElement.setAttribute('disabled', 'true'); 463 | } else { 464 | this.domElement.removeAttribute('disabled'); 465 | } 466 | } 467 | 468 | /** 469 | * Handling click event from button 470 | * @private 471 | */ 472 | __onXRButtonClick() { 473 | if (this.session) { 474 | this.options.onEndSession(this.session); 475 | } else if (this._enabled) { 476 | let requestPromise = this.options.onRequestSession(); 477 | if (requestPromise) { 478 | requestPromise.catch((err) => { 479 | // Reaching this point indicates that the session request has failed 480 | // and we should communicate that to the user somehow. 481 | let errorMsg = `XRSession creation failed: ${err.message}`; 482 | this.setTooltip(errorMsg); 483 | console.error(errorMsg); 484 | 485 | // Disable the button momentarily to indicate there was an issue. 486 | this.__setDisabledAttribute(true); 487 | this.domElement.setAttribute('error', 'true'); 488 | setTimeout(() => { 489 | this.__setDisabledAttribute(false); 490 | this.domElement.setAttribute('error', 'false'); 491 | }, 1000); 492 | }); 493 | } 494 | } 495 | } 496 | 497 | /** 498 | * Updates the display of the button based on it's current state 499 | * @private 500 | */ 501 | __updateButtonState() { 502 | if (this.session) { 503 | this.setTitle(this.options.textExitXRTitle); 504 | this.setTooltip('Exit XR presentation'); 505 | this.__setDisabledAttribute(false); 506 | } else if (this._enabled) { 507 | this.setTitle(this.options.textEnterXRTitle); 508 | this.setTooltip('Enter XR'); 509 | this.__setDisabledAttribute(false); 510 | } else { 511 | this.setTitle(this.options.textXRNotFoundTitle); 512 | this.setTooltip('No XR headset found.'); 513 | this.__setDisabledAttribute(true); 514 | } 515 | } 516 | } 517 | 518 | /** 519 | * Function checking if a specific css class exists as child of element. 520 | * 521 | * @param {HTMLElement} el element to find child in 522 | * @param {string} cssPrefix css prefix of button 523 | * @param {string} suffix class name 524 | * @param {function} fn function to call if child is found 525 | * @private 526 | */ 527 | const ifChild = (el, cssPrefix, suffix, fn)=> { 528 | const c = el.querySelector('.' + cssPrefix + '-' + suffix); 529 | c && fn(c); 530 | }; 531 | --------------------------------------------------------------------------------