New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Text Outline #73
Comments
This shouldn't be too hard to do. I started playing with textOutline as a feature a while ago but never finished it. In theory, the glyph edges can be expanded simply by changing the value in the signed distance field that corresponds to the edge. That's Ideally the shader could handle drawing both the normal glyph plus any outline around it in a single draw call, so you'd need a couple new uniforms: the outline width (not sure what units this would be in?), and the color of the outline area. |
I would be VERY happy if it was natively supported :) |
I got back into this a bit and quickly realized why I hadn't finished it originally. While the SDF can be used for outlining, there are some caveats:
So larger glyphs like "W" can get a decently thick outline, but small ones like periods can only get a very thin outline. And each glyph needs to use a different SDF cutoff point to make their outlines visually consistent. I don't think this is insurmountable yet, but isn't as simple as I'd thought initially. |
@lojjic, loving this library 🥇, but text outline is a requirement for our project...
I am curious, can we do some kind of post processing using shaders to achieve an outline and/or shadow effects? Thanks in advance! Edit: this is from mapbox gl, I think they're using SDF as well https://blog.mapbox.com/drawing-text-with-signed-distance-fields-in-mapbox-gl-b0933af6f817, maybe we can dissect their code to see what they did. https://github.com/mapbox/mapbox-gl-js |
Thanks for the info about Mapbox, @anzorb. I'll definitely check out their implementation more closely, but from that blog post (they call the outline a "halo") it sounds like they use the SDF alpha threshold approach like I mentioned before. But their SDF generation and atlas packing strategy is different than mine and doesn't have the same issues with non-uniform distance field sizes per glyph. It would probably have the same max outline width limitation though. I've actually made some progress on screen space outlines using standard derivatives. Screen space meaning the outline would be e.g. 1 screen pixel wide regardless of the text's size/scale or obliqueness. If the goal is just to add contrast with the background, it seems like that might be as good as (or even preferable to) outlines that scale along with the text size. Do you have an opinion on that? |
Standardized widths are far more preferred than scaling widths. It would behave more like css in this way too. |
Thanks for your prompt response @lojjic! The goal is to add contrast, and your proposal looks great. Do you mean the outline will be static? i.e. no way to control it (as @stephencorwin described, using text-shadow-ish approach). Honestly, I don't see that as a problem, as long as the 1 pixel is visible on super high density displays (because we currently multiply the font-size by device pixel ratio to achieve the "same" proportions, regardless of density), I think your proposal means the outline will be visibly thinner on higher DPI devices, no? Thanks in advance! |
Yes, that's one downside of the screen-space approach. That may be fine for many scenarios but it would bother me as a designer to have a different look across devices. The other big downside is that (I think) increasing the halo thickness would have performance impact due to requiring more texture reads. I need to keep researching this though. |
I too need this feature. I have implemented such a feature in an Android app I developed ages ago and have used just for myself and friends. I've started re-implemented this app with React and came to find your project. On your comment on more texture reads, I do not think you should worry about that much. The additional texels you need will be the texels neighboring fragments need and thus I think they are going to be in the cache of the GPU anyways and they will cost close to nothing. |
Thanks for the input @FunMiles. (Colorado represent! 😄) I think you're probably right for outlines of 1, maybe 2 fragments thick. My concern is that as the thickness increases, it's both exploding the number of texture reads (thickness of 4 = 56 reads?) as well as the likelihood those reads will be more expensive. But I'm just speculating based on reading some things, I'm no expert on GPUs by any means. I'll probably just have to try it out. |
@lojjic (North of you here 😄 ) I tried to find the code for the fragment shader. I admit to have a hard time figuring things out as it seems (and I think I read it somewhere) that you modify the shader instead of the writing it entirely. Right now you read a scalar distance for the pixel with I think it is possible to figure out what is needed with at most 5 texel reads or with the use of derivatives (I see you do check if they are available). |
There is a small bit of extra space around each glyph's true path bounds, just enough to accommodate the outer parts of the distance field. That's 8 texels in the SDF, but since each SDF is a uniform 64x64 scaled onto a glyph quad of varying size, that visible margin varies glyph to glyph.
That info should be available. I'm not following you on "project the current UV to the bounds and go read the data right inside" though. I'd be grateful if you know a way to get thicker outlines with fewer texel reads! I've been conceptualizing it as: for a fragment not inside the glyph path, do a radial search to see if any fragments within r= would fall within the glyph path. That may just be the wrong way to think about it. |
One issue is what happens when glyphs are put together side-by-side. Putting that aside for now, let's take the case of a single character being drawn with a fairly thick outline My understanding is that Does this answer your question? Now when you have several characters side-by-side, then each has to be aware of the neighbors, making this a bit more complex. You need to carry the GlyphUV and TextureUV not only for the current character, but also for the neighbor. That may require to cut each character into two rectangle, so that the neighbor can be the one before for the first rectangle and the one after for the second rectangle. I am not sure how to deal with multi-line.... I did not have to deal with that case in my application. My application was a map and each label was single line. |
PS: I may have misunderstood your "extra space around each glyph's true path bounds" . Are you saying that for an X, for example, it is encased into a rectangle and that there are four triangles that have no valid values? |
@FunMiles I think maybe I see where you're going. I'll need some time to think it through, but have other deadline work I need to focus on at the moment. |
PS: A little bit of fun math note: |
Although it is definitely not performant, it is possible to use offsets. I'm using import React from 'react';
import {Text} from 'drei';
const StrokedText: React.FC<
{
strokeWidth?: number;
strokeColor?: string;
strokeResolution?: number;
bold?: boolean;
} & any
> = ({
strokeWidth = 1,
strokeColor: color = '#000000',
strokeResolution = 100,
position,
bold,
...props
}) => {
const font = bold ? FONTS.BOLD : undefined;
const sharedProps = {
...props,
font,
color,
sdfGlyphSize: 12,
debugSDF: true,
};
let zOffset = 0;
const offset = () => (zOffset += 0.001);
return (
<group name="Stroked Text" position={position}>
{Array(strokeWidth)
.fill({})
.map((_, i) => {
const s= i / strokeResolution;
return (
<React.Fragment key={i}>
{/* <Text {...sharedProps} position={[-s, 0, offset()]} />*/}
{/* <Text {...sharedProps} position={[s, 0, offset()]} />*/}
<Text {...sharedProps} position={[0, -s, offset()]} />
<Text {...sharedProps} position={[0, s, offset()]} />
<Text {...sharedProps} position={[-s, -s, offset()]} />
<Text {...sharedProps} position={[s, s, offset()]} />
{/* <Text {...sharedProps} position={[-s, s, offset()]} /> */}
{/* <Text {...sharedProps} position={[s, -s, offset()]} /> */}
</React.Fragment>
);
})}
<Text name="Text" {...props} position={[0, 0, strokeWidth ? offset() : 0]} />
</group>
);
};
export default StrokedText; Then elsewhere, I can call it with <StrokedText
color="#ffffff"
fontSize={fontSize}
clipRect={[-0.5, -0.15, 0.5, 0.15]}
textAlign="center"
position={[0, 0, 0.01]}
>
{text}
</StrokedText> |
@FunMiles I've finally had some time to parse your suggestions. I think that would make sense assuming the entirety of the glyph's SDF rectangle were to contain useful (> 0.0) distance values. That's not currently the case; I think you were realizing it in your followup about "x", where the SDF falls off to zero well within the quad's bounds, so there are significant areas where there is no useful distance gradient from which to extrapolate: Perhaps I can look into changing the SDF generator to ensure a nonzero gradient across the entirety of the quad...? Thinking out loud, that could have connotations for distance precision, and could introduce artifacts between characters within the atlas texture... 🤔 |
I've tried that, and yes it allows the distance field to be extrapolated beyond the quad bounds, but as I feared it lowers the quality of the glyph itself significantly. That's to be expected with only an 8-bit gradient; spreading it out over a larger distance results in a lower precision at each texel. :( Perhaps I could generate a separate SDF for just the outlines -- a higher-spread but lower-precision field -- encoded into a second texture channel. It would double the texture size but shouldn't be significantly slower. 🤔 |
@lojjic That sounds very reasonable and basically mimics my workaround right now. Plus, it can be opt-in, so there is no perf hit if the user doesn't ask for outlines. |
@lojjic The opt-in approach suggested by @stephencorwin would make sure that nobody pays more than they're ready to pay. Back to technical aspect. Let's take the X image in your response. Am I understanding correctly that for all the glyphs, it is 64x64 pixel? With 8 bits, if you have to encode the whole distance space, that means you have an accuracy of 2 fractional bits. I guess you are stating that this accuracy is not enough. There's perhaps a trick to be used to gain in accuracy at very little cost. Instead of doing a grid of 64x64 of 8 bits, compress the data of 2x2 blocks into a 32-bit data. One clear feature of the signed distance between two pixels is that it is always within [-1,1] right and left, up and down and [-sqrt(2), sqrt(2)] in diagonal. So imagine you use 12 bits for the SD of the center of the 2x2 block, you would have 5 bits for each center of the pixels to encode the difference between their center and the center of the 2x2. These 5 bits represent at most sqrt(2)/2 distance. Effectively you have a 5.5 bit accuracy. One interesting consequence of this approach is that one would not let the texturing hardware do any interpolation. But on the flip side, one would get gradients for free. If you need help, I could implement the encoding/decoding of all this. PS: I have a memory of seeing a discussion in one of the README.md about a more sophisticated SDF. Did I dream it? 😛 Can someone point me back to it to see if that other system could help here? |
@FunMiles Very clever compression idea. I'll keep that in mind if other options fail. Losing linear interpolation by the hardware is a tradeoff I'd rather not have to make. 😉 It occurred to me that my problem with not enough bits is exacerbated by using 0.5 as the "zero" distance, so only half the alpha values are available for encoding the distance outside the glyph. I could potentially shift that to use more values for outside distances and fewer for inside distances, gaining some precision on the periphery. Re. a "more sophisticated SDF", you may mean "MSDF" where multiple color channels are used? |
I don't think you should worry about losing that interpolation. The cost is very low but the benefits of always having the gradient (and even a bit of curvature) when no in hardware support is there is of greater importance in my view. In effect, you round the sample point and get a new offset value to the rounded sample point. Computing the partial covering of the fragment for anti aliasing proceeds as it would normally do with the gradient being available.
That will gain you at most one bit of accuracy. Not to be sneezed at but not that significant still.
I don't think the MSDF would help, by the way. |
The SDF is built here: https://github.com/protectwise/troika/blob/master/packages/troika-three-text/src/worker/SDFGenerator.js#L134 -- not really much to explain about it, mostly mapping texels to font units and writing the measured distances into the texel values. We'd have to add some extra distance measurements in there for the centerpoint of each 2x2 block. Trying to wrap my brain fully around this... Replicating bilinear interpolation in the GLSL looks simple/cheap enough, once you have the 4 nearest values. To get those values when they're encoded with your compression scheme, I think it will involve:
Am I grokking that correctly? |
Oh hold up, I was still thinking of a single-channel texture. Using four rgba channels, each data block is only a single read. So at most 4 texture samples. |
I had started reading SDFGenerator.js. I will look at it more. I don't think you ever need more than reading a single texel per fragment, unless you want to specifically improve the alpha blending for corner cases where you might be at a point surrounded by glyph edges on all sides. You just need the closest center of the 2x2 block where the center of the fragment is. All these are available in WebGL 2.0 but I presume we want to target WebGL 1.0 here? |
I think it might be reasonable to restrict text outline to webgl 2 and just detect if the user has that browser compatibility. Although, I can understand possibly doing both a webgl 2 optimal version with a webgl 1 fallback. Imo we could start with webgl 2 and let that soak with the community before immediately trying to support both. |
I think I have an alternate approach to getting around the precision issue, so @FunMiles don't worry about fighting with the compression stuff for now. |
hey @lojjic, just checking in on this. Any progress or things that we can help with? Side note: Safari is finally coming around to supporting WebGL2, so it might be possible to not support WebGL1 for this feature.
|
I have an iPhone 6 on which there will never be WebGL 2.0 😒 Interestingly, the WebGL 1.0 specifies even worse requirements than I thought. Integers do not really exist:
However I have not lost hope. I just have to rethink a bit... |
@FunMiles Sure. I'm able to extend the distance field to the edges of the texture, while still maintaining sufficient precision for the glyph's shape, by encoding the distance values using a non-linear scale. So distances very near the glyph's path (within 1-2 texels) have many values to work with, while those further away have fewer. The shader just has to know how to convert back to the original linear distance. The glyph's proper shape looks great, and the lower precision for the extruded outlines is hardly noticeable, as they are rounded off anyway. I've proved this out using both a two-tier linear scale, and an exponential scale. Both have pros and cons. I'm now in the middle of implementing your (very smart) approach of using neighboring edge values to approximate the gradient outside the quad. It's making sense so far, though I'm not sure what to do about the areas outside the corners. It may become obvious once I get further into it, but if you've got an easy answer for me I'd be thankful :) |
I am not sure what you mean by outside the corners. Do you mean the quarter planes rooted at each corner, where the closest projection is the corner itself? I think there you can use the rule on an edge on both the vertical and horizontal edge and do a weighted average based on the distance to each. PS: The idea of reducing the accuracy far away had occurred to me earlier today after reading the WebGL integer nonsense... 😛 I still have hope that the compression technique can be implemented in WebGL 1.0 to obtain 11 bits of accuracy cheaply and a few more bits with more tests. i.e. using an |
Yeah that's what I meant, the areas where both U and V are outside 0-1. Thanks, I'll give that a shot. |
@lojjic As an aside question, in a previous post, you seemed concerned about having two texture values per SDF texel because of memory. Though I myself prefer to reduce memory, 256 glyphs on a 64x64 grid are only consuming 1/4MB of texture memory. I know some languages may require many more glyphs, but still, the BBC says that to read the newspaper, only 2000/3000 characters are needed. So 4000 characters would make for 4MB with one byte per texel SDF. Worth worrying about? |
@FunMiles Fair point, and I wasn't actually very concerned about that tbh. |
Some outstanding work left to do, but b19cd3a fixes this issue. For those using react-three-fiber, this has been released in drei v2.2.0 |
@FunMiles Looks nice! You should be able to use a numeric value for And thank you for your input along the way. I still wasn't able to get a smooth extrapolation of distance outside the quad bounds using the sort of technique you mentioned, so a thick outline currently has lumpy bits at the corners in particular, but it's good enough for most cases (up to ~10% the font size). I'm definitely open to trying to refine that still. |
@amcdnl Would you mind moving your support question to a separate discussion, so we're not spamming all the contributors of this original issue? And if you can include a working codesandbox or similar showing your issue that would be helpful in diagnosing. Thanks. |
I'm trying to figure out how to have a black text outline around white text so that the text can be easily read. I'm not sure, but it sounds like I might need to use a shader to do with with troika-three-text? Can you provide some guidance on how to achieve this effect?
Thanks!
The text was updated successfully, but these errors were encountered: