From 92ed7f774ed0d9fd81e5ea561a9e28c4927ce985 Mon Sep 17 00:00:00 2001 From: manthrax Date: Tue, 11 Apr 2023 01:18:02 -0700 Subject: [PATCH] updates reduce metalness , increase roughness.. add primitive save/load update readme.. --- MonkeyPaint.js | 2 +- README.md | 36 ++++++++++ ScenePainter.js | 186 +++++++++++++++++++++++++++++++++++++++++++----- monkeh.glb | Bin 3001728 -> 3001564 bytes 4 files changed, 205 insertions(+), 19 deletions(-) diff --git a/MonkeyPaint.js b/MonkeyPaint.js index ca0103b..4297479 100644 --- a/MonkeyPaint.js +++ b/MonkeyPaint.js @@ -143,7 +143,7 @@ let onWindowResize = (event)=>{ camera.aspect = width / height; camera.updateProjectionMatrix(); } -; + onWindowResize(); window.addEventListener("resize", onWindowResize, false); diff --git a/README.md b/README.md index 30d55fb..2a11191 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ # monkeypaint An app for painting on meshes in THREEJS, with glb export. +Try it here!: https://manthrax.github.io/monkeypaint/index.html?1 + + +This implements 3d mesh painting using via the gpu and rendertargets. + +Algorithm: +First, the UV map is rendered as a 3d model onto a renderTarget in white, to form a binary mask of which pixels are covered by the UVmap + +Then the UV map is rendered a second time, with the models texture bound. The 3d vertex coordinates are also available since +the UV rendering behavior is injected into the material using shader injection. + +As each UV triangle is rendered.. the vertex coordinate is transformed to world space and compared with the Brush position in worldspace +(derived from the cursor raycast) +This computes an intensity 0 to 1 for the brush affecting this texel of the UV map. +Brush color is mixed with existing texel color and output. + +Next, a "dilation" shader is run with the UV mask texture bound. For every texel not underneath a UV triangle (i.e. edges of UV islands) +The nearest texel that is in a UV island (within 16 pixels or so) is found , and that pixel color is output. +This step eliminates most of the seams that occur due to the filtering of textures, +by giving the islands a 16 or so pixel padding for the filtering to access on island boundaries. + +Export: +intermediate rendertargets containing the painted texture, are converted to canvas texture, then run through +the binary GLTF exporter as a binary gltf file (.glb) + + +Save/Load/Reset: +these are a bit janky.. When you hit Save, all the brush strokes in the seesion are saved to localstorage. + so if you reload the page, and then hit Load, it will repaint the model as you had it before. +Reset clears the current localStorage save and reloads the page. + +TODO: +implement custom brush shapes. right now it's just a variable sized sphere.. and doesn't orient along the ray hit normal. +Allow texture stamping as an extension of this. + +Allow specifying different texture outputs for roughnessmap, metalnessmap, opacitymap, and normalmap. diff --git a/ScenePainter.js b/ScenePainter.js index dd14147..34a1294 100644 --- a/ScenePainter.js +++ b/ScenePainter.js @@ -10,6 +10,7 @@ document.title = "MonkeyPaint by thrax" //---------------texpaint let scenePainter new GLTFLoader().load("monkeh.glb", (glb)=>{ +//new GLTFLoader().load("CartoonTV_bake.glb", (glb)=>{ scenePainter = new ScenePainter(glb.scene); } ); @@ -50,7 +51,7 @@ function ScenePainter(paintScene) { let query = window.location.search.substring(1).split("="); let id = parseInt(query); if(!((id>=0)&&(ide.isMesh&&e.material&&(e.material.envMapIntensity = v)); } + /* + let {min,max,PI} = Math; + function indexedGeometryTo2dPerimeter(geom){ + let arr = geom.index.array; + let edges = [] + for(let i=0,l=arr.length;ia[0]-b[0]); + let uniqueEdges=[] + for(let i=0;i{ const exporter = new GLTFExporter(); @@ -255,13 +295,20 @@ brushInfluence = max(0., brushInfluence - smoothstep(brushInfluence,.1,0.)); } let drawing = false; + let buttons = 0; document.addEventListener('pointerdown', (e)=>{ - drawing = !(controls.enabled = !(cursorNode.raycast(paintMesh).length > 0)); + buttons = e.buttons; + if(buttons==1){ + (controls.enabled = !(cursorNode.raycast(paintMesh).length > 0)); + if(!controls.enabled) + drawing = true; + } } ); document.addEventListener('pointerup', (e)=>{ controls.enabled = true; drawing = false; + buttons = e.buttons; } ) @@ -280,21 +327,115 @@ brushInfluence = max(0., brushInfluence - smoothstep(brushInfluence,.1,0.)); renderer.setClearColor(0x101010); + let replay=[] + let replaying = false; + let replayCursor = 0; + let repeatCountdown=0; + + let load=()=>{ + try{ + if(localStorage.monkeyReplay){ + replay = JSON.parse(localStorage.monkeyReplay); + if(replay.length)replaying = true; + } + } + catch{ + alert("Couldn't parse localstorage! Replay lost...") + replay = [] + } + } + let getState = ()=>{return { + uBrushPoint:uBrushPoint.value.clone(), + uBrushNormal:uBrushNormal.value.clone(), + uBrushSize:uBrushSize.value.clone(), + uBrushColor : uBrushColor.value.clone(), + uBrushHardness : uBrushHardness.value, + uBrushStrength:uBrushStrength.value, + _repeat:1, + }} + let setState=(st)=>{ + uBrushPoint.value.copy(st.uBrushPoint) + uBrushNormal.value.copy(st.uBrushNormal) + uBrushSize.value.copy(st.uBrushSize) + uBrushColor.value.copy(st.uBrushColor) + uBrushHardness.value=st.uBrushHardness + uBrushStrength.value=st.uBrushStrength + // console.log(st.uBrushPoint) + } + let statesEqual=(sa,sb)=>{ + if(typeof sa == 'object'){ + for(let f in sa) + if((!f.startsWith('_'))&&(!statesEqual(sa[f],sb[f]))) + return false; + }else + return (sa==sb); + return true; + } + + let lastState; +// window.onBeforeUnload=()=>{ +// if(replay.length){ +// localStorage.monkeyReplay = JSON.stringify(replay); +// } +// } + +gui.add({save:()=>{ + localStorage.monkeyReplay = JSON.stringify(replay); + console.log("Save size:",localStorage.monkeyReplay.length) +}},"save") +gui.add({load},"load") + +gui.add({reset:()=>{ + replay=[] + //delete localStorage.monkeyReplay; + location.reload(); +}},"reset") + let updateReplay=()=>{ + if(replaying){ + if(replayCursor>=replay.length){ + replaying = false; + lastState = undefined; + }else{ + let state = replay[replayCursor]; + if(!repeatCountdown)repeatCountdown=state._repeat; + else{ + repeatCountdown--; + if(!repeatCountdown)replayCursor++; + } + setState(state); + } + }else{ + let state = getState(); + if(lastState){ + if(statesEqual(state,lastState)) + lastState._repeat++; + else + replay.push(lastState) + } + lastState=state; + } + } let draw = ()=>{ let fbt = texTransformer.feedbackTexture; + let iterations = 0; + do{ + try{ + updateReplay(); + } + catch (e){ + console.log(e) + replaying = false; + replay=[] + } uvMesh.material.map = fbt.renderTarget.texture; texTransformer.renderUVMeshToTarget(fbt.offRenderTarget); - - // texTransformer.dilateTexture(); - // texTransformer.dilateTexture(); - previewPlane.material.map = fbt.renderTarget.texture; paintMesh.material.map = fbt.renderTarget.texture - uvMesh.material.map = fbt.offRenderTarget.texture; - dilator.apply(fbt); - + iterations++; + }while(replaying&&(iterations<100)) + dilator.apply(fbt); } //Convert rendertarget to canvasTexture @@ -305,11 +446,17 @@ brushInfluence = max(0., brushInfluence - smoothstep(brushInfluence,.1,0.)); canvas.height = height; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(width, height); - const buffer = new Float32Array(width * height * 4); - renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, buffer); - for (let i = 0; i < buffer.length; i++) - buffer[i] *= 255; - imageData.data.set(buffer); + if(renderTarget.texture.type==THREE.FloatType){ + const buffer = new Float32Array(width * height * 4); + renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, buffer); + for (let i = 0; i < buffer.length; i++) + buffer[i] *= 255; + imageData.data.set(buffer); + }else{ + const buffer = new Uint8Array(width * height * 4); + renderer.readRenderTargetPixels(renderTarget, 0, 0, width, height, buffer); + imageData.data.set(buffer); + } ctx.putImageData(imageData, 0, 0); return canvas; } @@ -326,10 +473,9 @@ brushInfluence = max(0., brushInfluence - smoothstep(brushInfluence,.1,0.)); //paintMesh.worldToLocal(n); n.sub(v); - if (!drawing) - return; - draw(); } + if ((buttons==1)&&(drawing||replaying)) + draw(); } //This shader renders a models UV coordinates as polygons, and applies the influence of the brush... rendering the current brush stroke when mouse is down... @@ -389,7 +535,7 @@ function FeedbackTexture(texture, renderer) { let makeTarget = this.makeTarget = ()=>{ let rt = new THREE.WebGLRenderTarget(texture.image.width,texture.image.height,{ format: THREE.RGBAFormat, - type: THREE.FloatType, + type: THREE.UnsignedByteType,//THREE.FloatType, //minFilter: THREE.NearestFilter, //magFilter: THREE.NearestFilter, depthBuffer: false, @@ -586,6 +732,10 @@ let fps = 60; let takeScreenshot = false; let exportTriggered = false; + +let {MOUSE} = THREE; +controls.mouseButtons = { LEFT: MOUSE.PAN, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.ROTATE }; + renderer.setAnimationLoop((dt)=>{ let time = performance.now(); diff --git a/monkeh.glb b/monkeh.glb index d3de90be30cd7f3cdb12136eadbaae010c0c4648..2071ffe455523cde53d52094acfd37de13ff32b4 100644 GIT binary patch delta 3512 zcmc)Ke^88h9Ki8?cJ0!4cWo)O>Ul!DXtT6YqV{=OqMYuy2zM9_MXQb)zv8+!lPRHabx^+-hRHm-ny-4sN}7Q z!Zjq#)|sNvO${P*gNPytNv-Zh5|bpP-lRULL1b6;2P!esaYB#vHOJX)v!d|5iWIg} zL)|O86|g#;wuiOpv<#6)tOSk_fg)threF1j)lx8DJN;JbSXVw2k2;{_-fOgAJH3NK z59J`O7fLRm&q`4s1Mc=R?H=72kl~ZWtxLFP>8iK*Bw-WTuhcaLbk_n8UoLFqZoTZ7 zHzwBD{^;DW&K+>j`3UWW0#!gIfmz3C=Z=RA79FQ6QG_Q*5$ndC@YbJtx&$9Pc#Z9n z5sm4>QK(`eD3JC)U-MjFc3>*b<pY7{oo>T zBw4nQWm{)fms!fi?4C&xINzGY&PR2t!AeS-q<+i~Zk~%b zuUWy`dUjGhzn|<~P4;#e>BP(?xm+tLWH%1`oS%5q$Y~*!3Tno$znIEfn~mIskbJ(~ zUxe)Avz~0=m+Ot3t-~aRd_6fnjvNm|cAJ5tJ!*7k22&^if<$K&t7n`@%%nb$Nf62 zl(qHj@;Ca1bIUk)RK?m_pcu+d2#n^Yr~kmV8PY204!Iv$nnjkRilMw8I(LNmSpvWI zl*-Jdo>EflUpc8SNkOtFIgt9197#^3{-gn=k~E0qLUKi=p7iNYQN%MlKdN<% zl-t2L5m2>0oMBNm?F9=&Kn80?AOia3&|||#)ByabHk>Oz@xA4J7K;6SjNdxp$oLst zr$$vdv`B19KC_U&ATx3f56am8>ZuRP_(*ckPvo9%TJqNcWB_0bg)ZjNtNOJLvC~4G zVu_V6Fht>uoW5+AjC_WG7RuPwfoD4)yF2jO_QPyedNC>3}! zBzW&29IW!k;Kb~qy5!5xR(9kel8zXx&Xh^=zbZ!R0Kmkz%5f{fPclM*DrU$-eiB1-oK)|c5`~bo{E}>AGs{#iP0eq@R4+XK0`8}e+j=49mV$1zT|^) z=Wv}_pnb^(l08ae6#HJI550klP#w6|jvY8Un4O0cxOW<5j0K}T(bC2H%q%qZfvxKkZrfgwl<^GZJ_^>)oyN; zmM_K8xUS^_-)?KjW&VYEwq8j`<2FMFGn3yso|_y{Rvxoc_`eTnPx1*4(p({I{Em^q zCP!%&OxVT9P@I!Alq~LHBIMR)f41SrAbjeoE4D(zK&hiV@a}E?b>B+;)pd(;AT;h} zinR(p)8gc|7Sol<{6DOyv|JtNhImCvt9Y*m==}fBs);(!#A-QVxhv*_K0RPlPrdlvCJiGECygL^kTfJu(nwUiNPB77pX^5brT_o{ delta 5148 zcmc)Oc~lff8US!+K!F}cRKx@9;gSG01JiSlFje3IPlE@C!3ZdzqDDkS3}9Rb55^-9 zqU_pXcfBt*9t)_H2XtW)uQjd$;u=Mf#O%63ljjQ0;FT?|$t00w_-Fa!>-X!b@2jup z&AjS*(@=~Axqm_e@xs)YFrk%|Rqj=(RZE%pkYETKpkdWAwN5T)l{&RbD=)ez$kha> zWja=;QnIXErjqMejlXBcvXu)Lr7c*oA}ncM=CTaWak2oJMyX-tYK2m!RjXxMt*)p_ zIJRiHFhUccQY+-FR>>+gT7^=h(f*HmoleK9RWg;9Rg(JmwE=RCPNS1+R4T1fE+@;X z-&jl<>y&a``-96AS}m(xudv>zuojRIl4!RLi2)=AlCULVhZNTK=UNk9zM^2U@7ws~ zu6$sL|MQB%`$_&0lHaSj$ToY0wCTMp{QS|ofEnckdNnF*vp+82vNzM8crWsb0Oz*R zc!zuf?$MmbOM8tWQhtw*;KM+XHVOCUT@@=}+~T?5N?jbV#5*fi;AtekhUE9)ft?fR z$rk$10B(bjD&dq{=|u1PGiUX&*CNw3IoZusmij~0 z8oZk1k765w`)Yxo8bu8qX}SKhXDPVi*d1Vz(Q+9TJ`QF7NGl!uDg!;SPc{G* z<3nJ#TT&yT+h7sizBCk=jGVJ5AA!M@jISl*&r>6Dw@;4`!Dq=j6j{gIR;>YeM!ph1 zvBlR4+TUFDhDSPGbK(D`hN5r`Mm z#sbc76`mDPSGfRH*-EeoRpwEZj!5ez2@#@G92Gwz^i=wK5OCBD{7sq(yKVDBI@}k* zfhhuCVD1l2roj=0ePElN6qwA}>m@}}!^t#kO{Ox*l)3GHNXI+KQZKE1fywByUJ@h_ zD=ub1yJt=y=Pyw}!3}{DpU}WiF@9&(7LfdMGVJ!8my@xYj87)x=DuvT5~sfKhAm&9}#-@`VN&cOy9r?fZpkGdK=iI0}QsJ7yay~8^PF_A3_G(mf zjNT|P`2Xa}r(UrK|2(t`_vXHC)#mx_TlMeEw(Ty@?%2cMF!76VE3$7#cJ131n`6u9 z30$fdC6g9b-QrWW%m*J_O2oaIt0|+y4bhP-IPMdOC&a6;rS?f>freU*KVDED+*hkp zSzb+Qza+K9QRX+j+9qo+2tVM0!^Z^S9?ku`oDM>xy{UX_w(Q0g{SCV%xH$C--Xe2N z74%`h?8>*qNe>PKACKkm zA#w?RgHwJpvE;#F-kTKUopK4b#F^FCz(qz6J<9E2k4AeKYOamp0D1AxA%}nq&r)Dt z+^0jsuMQa>MmxdF`=;Vso-{JZsNw+R{+KRRqQSqXHVOS6tj!0XCi(MdP6jl0Y2!99 zJTQT8ev}3~%$?g#L65J=?a{_<{O4q<-HmJW`O%~tlkyIurZJLlfb*$SC;66wM0oR) z1HfcXe3SALmw#{@oUR@ZJZB}ta!!AfavKtVBnhU__w?_MZw4LamfoZ|L(cj0U}wuj z>=-J<-M*h-g(oFru)8`On_F-Dn*Y51DRBQvhs_;q`x@LL<1S>}+|3DAP(#+)Mb_@8EWi^%UUPt~8U;d!PJZsUoAl-$mj4zk`Am;PWPZThcPstifR$RTZk!IxowUv$Cj3i< zzfPDAOy<}PlEcm<4cm~C*v^tpb5Ax%T&1KVZY15~JG+~__1l$rwP-DW#pa3LWR&!b zsuOr*)DMRHb4v7u_EE6im$wIkp<}1Pny?|A=A;`XZepjW7r?^CXy`rn8gSy)woy*r z*W!%8zqAtMPMr_itJS2IZDDsI>uhFgyb;FP9nz5%rj5}g&G(w&kLYor|}BIiWyen}$w zc(|yQKebYmKR4jGykn|!*j~1bK*> zIcAN>0PJU